diff --git a/sharedUtils/index.ts b/sharedUtils/index.ts index 0927d1e79..f611787e2 100644 --- a/sharedUtils/index.ts +++ b/sharedUtils/index.ts @@ -51,11 +51,13 @@ export const getCellValueData = (cell: QuillCellContent) => { // Ensure editHistory exists and is an array const editHistory = cell.editHistory || []; - // Find the latest edit that matches the current cell content - const latestEditThatMatchesCellValue = editHistory - .slice() - .reverse() - .find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent); + // Find the latest edit that matches the current cell content (strict match). + // Falls back to the latest value edit if the strict match fails, which can happen + // when the merge step during save subtly normalizes the stored value. + const reversed = editHistory.slice().reverse(); + const latestEditThatMatchesCellValue = + reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent) ?? + reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && !edit.preview); // Get audio validation from attachments instead of edits let audioValidatedBy: ValidationEntry[] = []; @@ -148,16 +150,26 @@ export const cellHasAudioUsingAttachments = ( if (selectedAudioId && atts[selectedAudioId]) { const att = atts[selectedAudioId]; - return att && att.type === "audio" && att.isDeleted === false && att.isMissing !== true; + return att && att.type === "audio" && !att.isDeleted && att.isMissing !== true; } return Object.values(atts).some( - (att: any) => att && att.type === "audio" && att.isDeleted === false && att.isMissing !== true + (att: any) => att && att.type === "audio" && !att.isDeleted && att.isMissing !== true ); }; +// Count only active (non-deleted) validation entries. Requires isDeleted === false so legacy +// or malformed entries (e.g. missing isDeleted) are not counted as validated. +export function countActiveValidations(validatedBy: ValidationEntry[] | undefined): number { + return (validatedBy?.filter((v) => v && typeof v === "object" && v.isDeleted === false).length ?? 0); +} + export const computeValidationStats = ( - cellValueData: Array<{ validatedBy?: ValidationEntry[]; audioValidatedBy?: ValidationEntry[]; }>, + cellValueData: Array<{ + validatedBy?: ValidationEntry[]; + audioValidatedBy?: ValidationEntry[]; + cellContent?: string; + }>, minimumValidationsRequired: number, minimumAudioValidationsRequired: number ): { @@ -165,23 +177,95 @@ export const computeValidationStats = ( audioValidatedCells: number; fullyValidatedCells: number; } => { + // Only count a cell as text-validated if it has actual text content. Empty/placeholder + // cells should not inflate validation % when no validations are meaningfully applied. const validatedCells = cellValueData.filter((cell) => { - return (cell.validatedBy?.filter((v) => !v.isDeleted).length || 0) >= minimumValidationsRequired; + if (!hasTextContent(cell.cellContent)) return false; + return countActiveValidations(cell.validatedBy) >= minimumValidationsRequired; }).length; const audioValidatedCells = cellValueData.filter((cell) => { - return (cell.audioValidatedBy?.filter((v) => !v.isDeleted).length || 0) >= minimumAudioValidationsRequired; + return countActiveValidations(cell.audioValidatedBy) >= minimumAudioValidationsRequired; }).length; const fullyValidatedCells = cellValueData.filter((cell) => { - const textOk = (cell.validatedBy?.filter((v) => !v.isDeleted).length || 0) >= minimumValidationsRequired; - const audioOk = (cell.audioValidatedBy?.filter((v) => !v.isDeleted).length || 0) >= minimumAudioValidationsRequired; + const textOk = + hasTextContent(cell.cellContent) && + countActiveValidations(cell.validatedBy) >= minimumValidationsRequired; + const audioOk = countActiveValidations(cell.audioValidatedBy) >= minimumAudioValidationsRequired; return textOk && audioOk; }).length; return { validatedCells, audioValidatedCells, fullyValidatedCells }; }; +/** + * Cell-like shape used for progress exclusion checks (notebook cell or serialized cell). + */ +export type CellForProgressCheck = { + metadata?: { + id?: string; + type?: string; + parentId?: string; + data?: { merged?: boolean; parentId?: string; type?: string; }; + }; +}; + +/** + * Returns true if the cell should be excluded from progress (not counted in totalCells). + * Paratext and child cells (e.g. type "text" with parentId) must not count toward progress. + */ +export function shouldExcludeCellFromProgress(cell: CellForProgressCheck): boolean { + const md = cell.metadata; + const cellData = md?.data as { merged?: boolean; parentId?: string; type?: string; } | undefined; + const cellId = (md?.id ?? "").toString(); + + if (md?.type === "milestone" || cellData?.merged) { + return true; + } + const isParatext = + md?.type === "paratext" || + cellData?.type === "paratext" || + cellId.includes("paratext-"); + if (isParatext) { + return true; + } + if (!cellId || cellId.trim() === "") { + return true; + } + const parentId = md?.parentId ?? cellData?.parentId; + if (parentId != null && parentId !== "") { + return true; + } + return false; +} + +/** + * Returns true if the cell should be excluded from progress when already in QuillCellContent form. + * Use this to filter lists before computing validation stats so paratext/child never count. + */ +export function shouldExcludeQuillCellFromProgress(cell: QuillCellContent): boolean { + const cellId = (cell.cellMarkers?.[0] ?? "").toString(); + if (!cellId || cellId.trim() === "") { + return true; + } + if (cell.merged) { + return true; + } + const typeLower = (cell.cellType ?? "").toString().toLowerCase(); + if (typeLower === "milestone") { + return true; + } + if (typeLower === "paratext" || cellId.includes("paratext-")) { + return true; + } + const parentId = cell.metadata?.parentId ?? cell.data?.parentId; + if (parentId != null && parentId !== "") { + return true; + } + return false; +} + export const computeProgressPercents = ( totalCells: number, cellsWithValues: number, diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 2ea9b9335..4e38bbf6f 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -23,7 +23,7 @@ import { randomUUID } from "crypto"; import { CodexContentSerializer } from "../../serializer"; import { debounce } from "lodash"; import { getSQLiteIndexManager } from "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager"; -import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents } from "../../../sharedUtils"; +import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { extractParentCellIdFromParatext, convertCellToQuillContent } from "./utils/cellUtils"; import { formatJsonForNotebookFile, normalizeNotebookFileText } from "../../utils/notebookFileFormattingUtils"; import { atomicWriteUriText, readExistingFileOrThrow } from "../../utils/notebookSafeSaveUtils"; @@ -1551,24 +1551,19 @@ export class CodexCellDocument implements vscode.CustomDocument { const cellsForMilestone: QuillCellContent[] = []; for (let j = startIndex; j < endIndex; j++) { const cell = cells[j]; - - // Skip milestone cells - if (cell.metadata?.type === CodexCellTypes.MILESTONE) { - continue; - } - - // Skip paratext and merged cells - const cellId = cell.metadata?.id; - if (!cellId || cellId.includes(":paratext-") || cell.metadata?.type === CodexCellTypes.PARATEXT || cell.metadata?.data?.merged) { + if (shouldExcludeCellFromProgress(cell)) { continue; } - // Convert to QuillCellContent format const quillContent = convertCellToQuillContent(cell); cellsForMilestone.push(quillContent); } - const totalCells = cellsForMilestone.length; + // Only root content cells count for progress (exclude paratext/child again for validation) + const progressCells = cellsForMilestone.filter( + (c) => !shouldExcludeQuillCellFromProgress(c) + ); + const totalCells = progressCells.length; if (totalCells === 0) { // Milestone number is 1-based (i + 1) progress[i + 1] = { @@ -1582,7 +1577,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } // Count cells with content (translated) - const cellsWithValues = cellsForMilestone.filter( + const cellsWithValues = progressCells.filter( (cell) => cell.cellContent && cell.cellContent.trim().length > 0 && @@ -1590,15 +1585,15 @@ export class CodexCellDocument implements vscode.CustomDocument { ).length; // Count cells with audio - const cellsWithAudioValues = cellsForMilestone.filter((cell) => + const cellsWithAudioValues = progressCells.filter((cell) => cellHasAudioUsingAttachments( cell.attachments, cell.metadata?.selectedAudioId ) ).length; - // Calculate validation data - const cellWithValidatedData = cellsForMilestone.map((cell) => getCellValueData(cell)); + // Calculate validation data (only from root content cells) + const cellWithValidatedData = progressCells.map((cell) => getCellValueData(cell)); const { validatedCells, audioValidatedCells, fullyValidatedCells } = computeValidationStats( @@ -1681,18 +1676,9 @@ export class CodexCellDocument implements vscode.CustomDocument { const contentCells: QuillCellContent[] = []; for (let i = startCellIndex; i < endCellIndex; i++) { const cell = cells[i]; - - // Skip milestone cells - if (cell.metadata?.type === CodexCellTypes.MILESTONE) { - continue; - } - - // Skip paratext and merged cells - const cellId = cell.metadata?.id; - if (!cellId || cellId.includes(":paratext-") || cell.metadata?.type === CodexCellTypes.PARATEXT || cell.metadata?.data?.merged) { + if (shouldExcludeCellFromProgress(cell)) { continue; } - // Convert to QuillCellContent format const quillContent = convertCellToQuillContent(cell); contentCells.push(quillContent); @@ -1736,7 +1722,11 @@ export class CodexCellDocument implements vscode.CustomDocument { contentCellIdsForSubsection.has(c.cellMarkers[0]) ); - const totalCells = subsectionCells.length; + // Only root content cells count for progress (exclude paratext/child for validation) + const progressCells = subsectionCells.filter( + (c) => !shouldExcludeQuillCellFromProgress(c) + ); + const totalCells = progressCells.length; if (totalCells === 0) { progress[subsectionIdx] = { percentTranslationsCompleted: 0, @@ -1753,7 +1743,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } // Count cells with content (translated) - const cellsWithValues = subsectionCells.filter( + const cellsWithValues = progressCells.filter( (cell) => cell.cellContent && cell.cellContent.trim().length > 0 && @@ -1761,15 +1751,15 @@ export class CodexCellDocument implements vscode.CustomDocument { ).length; // Count cells with audio - const cellsWithAudioValues = subsectionCells.filter((cell) => + const cellsWithAudioValues = progressCells.filter((cell) => cellHasAudioUsingAttachments( cell.attachments, cell.metadata?.selectedAudioId ) ).length; - // Calculate validation data - const cellWithValidatedData = subsectionCells.map((cell) => getCellValueData(cell)); + // Calculate validation data (only from root content cells) + const cellWithValidatedData = progressCells.map((cell) => getCellValueData(cell)); const { validatedCells, audioValidatedCells, fullyValidatedCells } = computeValidationStats( @@ -1778,9 +1768,12 @@ export class CodexCellDocument implements vscode.CustomDocument { minimumAudioValidationsRequired ); - // Compute per-level validation percentages for text and audio + // Compute per-level validation percentages for text and audio. + // For text, only count validations on cells with actual content (same rule as computeValidationStats). const countNonDeleted = (arr: any[] | undefined) => (arr || []).filter((v: any) => !v.isDeleted).length; - const textValidationCounts = cellWithValidatedData.map((c) => countNonDeleted(c.validatedBy)); + const textValidationCounts = cellWithValidatedData.map((c) => + hasTextContent(c.cellContent) ? countActiveValidations(c.validatedBy) : 0 + ); const audioValidationCounts = cellWithValidatedData.map((c) => countNonDeleted(c.audioValidatedBy)); const computeLevelPercents = (counts: number[], maxLevel: number) => { diff --git a/src/providers/mainMenu/mainMenuProvider.ts b/src/providers/mainMenu/mainMenuProvider.ts index 49fa50d85..70fef2cb3 100644 --- a/src/providers/mainMenu/mainMenuProvider.ts +++ b/src/providers/mainMenu/mainMenuProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { getProjectOverview, findAllCodexProjects, checkIfMetadataAndGitIsInitialized, extractProjectIdFromFolderName } from "../../projectManager/utils/projectUtils"; +import { getProjectOverview, findAllCodexProjects, checkIfMetadataAndGitIsInitialized, extractProjectIdFromFolderName, disableSyncTemporarily } from "../../projectManager/utils/projectUtils"; import { getAuthApi } from "../../extension"; import { openSystemMessageEditor } from "../../copilotSettings/copilotSettings"; import { openProjectExportView } from "../../projectManager/projectExportView"; @@ -617,6 +617,12 @@ export class MainMenuProvider extends BaseWebviewProvider { // For backward compatibility, redirect to setValidationCount await this.executeCommandAndNotify("setValidationCount"); break; + case "setValidationCountDirect": + await this.handleSetValidationCountDirect(message.data?.count, "validationCount"); + break; + case "setValidationCountAudioDirect": + await this.handleSetValidationCountDirect(message.data?.count, "validationCountAudio"); + break; case "openEditAnalysis": await vscode.commands.executeCommand("codex-editor-extension.analyzeEdits"); break; @@ -1777,6 +1783,60 @@ export class MainMenuProvider extends BaseWebviewProvider { } } + private async handleSetValidationCountDirect(count: number | undefined, configKey: "validationCount" | "validationCountAudio"): Promise { + if (count === undefined || count < 1 || count > 15) return; + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri; + if (!workspaceFolder) return; + + disableSyncTemporarily(); + + const config = vscode.workspace.getConfiguration("codex-project-manager"); + await config.update(configKey, count, vscode.ConfigurationTarget.Workspace); + + let author = "unknown"; + try { + const authApi = await getAuthApi(); + const userInfo = await authApi?.getUserInfo(); + if (userInfo?.username) { + author = userInfo.username; + } + } catch (_) { + // Silent fallback + } + + const result = await MetadataManager.safeUpdateMetadata( + workspaceFolder, + (project: Record) => { + const meta = (project.meta as Record) || {}; + const original = meta[configKey]; + meta[configKey] = count; + project.meta = meta; + + if (original !== count) { + if (!project.edits) { + project.edits = []; + } + addProjectMetadataEdit( + project as Parameters[0], + EditMapUtils.metaField(configKey), + count, + author, + ); + } + return project; + }, + { author }, + ); + + if (!result.success) { + console.error("Failed to update metadata:", result.error); + } + + await this.store.refreshState(); + await this.updateProjectOverview(); + } + private async handleApplyTextDisplaySettings(settings: any): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 26b90f72a..0b08b225b 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -7,14 +7,13 @@ import { BaseWebviewProvider } from "../../globalProvider"; import { getWebviewHtml } from "../../utils/webviewTemplate"; import { safePostMessageToView } from "../../utils/webviewUtils"; import { CodexItem } from "types"; -import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents } from "../../../sharedUtils"; +import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { normalizeCorpusMarker } from "../../utils/corpusMarkerUtils"; import { addMetadataEdit } from "../../utils/editMapUtils"; import { getAuthApi } from "../../extension"; import { CustomNotebookMetadata } from "../../../types"; import { getCorrespondingSourceUri, findCodexFilesByBookAbbr } from "../../utils/codexNotebookUtils"; import { CodexCellEditorProvider } from "../codexCellEditorProvider/codexCellEditorProvider"; -import { CodexCellTypes } from "../../../types/enums"; interface CodexMetadata { id: string; @@ -45,6 +44,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private dictionaryItems: CodexItem[] = []; private disposables: vscode.Disposable[] = []; private isBuilding = false; + private pendingRebuild = false; private serializer = new CodexContentSerializer(); private bibleBookMap: Map = new Map(); @@ -432,10 +432,12 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private async buildInitialData(): Promise { if (this.isBuilding) { + this.pendingRebuild = true; return; } this.isBuilding = true; + this.pendingRebuild = false; try { const workspaceFolders = vscode.workspace.workspaceFolders; @@ -477,6 +479,10 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { vscode.window.showErrorMessage(`Error loading codex files: ${error}`); } finally { this.isBuilding = false; + if (this.pendingRebuild) { + this.pendingRebuild = false; + this.buildInitialData(); + } } } @@ -491,25 +497,34 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const metadata = notebookData.metadata as CodexMetadata; const fileNameAbbr = path.basename(uri.fsPath, ".codex"); - // Calculate progress based on cells with values + // Calculate progress based on cells with values (exclude paratext and child cells) const unmergedCells = notebookData.cells.filter( - (cell) => - !cell.metadata.data?.merged && - cell.metadata?.type !== CodexCellTypes.MILESTONE + (cell) => !shouldExcludeCellFromProgress(cell) + ); + // Second filter: only root content cells count for progress/validation (same as backend) + const toQuillLike = (cell: (typeof notebookData.cells)[0]) => ({ + cellMarkers: [cell.metadata?.id ?? ""], + cellType: cell.metadata?.type ?? cell.languageId, + merged: cell.metadata?.data?.merged, + metadata: { parentId: cell.metadata?.parentId }, + data: cell.metadata?.data, + }); + const progressCells = unmergedCells.filter( + (cell) => !shouldExcludeQuillCellFromProgress(toQuillLike(cell) as unknown as import("../../../types").QuillCellContent) ); - const totalCells = unmergedCells.length; - const cellsWithValues = unmergedCells.filter( + const totalCells = progressCells.length; + const cellsWithValues = progressCells.filter( (cell) => cell.value && cell.value.trim().length > 0 && cell.value !== "" ).length; const progress = totalCells > 0 ? (cellsWithValues / totalCells) * 100 : 0; - const cellWithValidatedData = unmergedCells.map( + const cellWithValidatedData = progressCells.map( (cell) => { const cellValueData = getCellValueData({ cellContent: cell.value, cellMarkers: [cell.metadata.id], - cellType: cell.languageId as any, + cellType: cell.languageId as import("../../../types/enums").CodexCellTypes, cellLabel: cell.metadata.cellLabel, editHistory: cell.metadata.edits, attachments: cell.metadata.attachments, @@ -520,7 +535,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { ); // Compute audio completion based on attachments (mirrors editor logic) - const cellsWithAudioValues = unmergedCells.filter((cell) => + const cellsWithAudioValues = progressCells.filter((cell) => cellHasAudioUsingAttachments(cell?.metadata?.attachments, cell?.metadata?.selectedAudioId) ).length; @@ -535,9 +550,12 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { minimumAudioValidationsRequired ); - // Compute per-level validation percentages for text and audio + // Compute per-level validation percentages for text and audio. + // For text, only count validations on cells with actual content (same rule as computeValidationStats). const countNonDeleted = (arr: any[] | undefined) => (arr || []).filter((v: any) => !v.isDeleted).length; - const textValidationCounts = cellWithValidatedData.map((c) => countNonDeleted(c.validatedBy)); + const textValidationCounts = cellWithValidatedData.map((c) => + hasTextContent(c.cellContent) ? countActiveValidations(c.validatedBy) : 0 + ); const audioValidationCounts = cellWithValidatedData.map((c) => countNonDeleted(c.audioValidatedBy)); const computeLevelPercents = (counts: number[], maxLevel: number) => { @@ -799,7 +817,15 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { codexWatcher.onDidDelete(() => this.buildInitialData()), dictWatcher.onDidCreate(() => this.buildInitialData()), dictWatcher.onDidChange(() => this.buildInitialData()), - dictWatcher.onDidDelete(() => this.buildInitialData()) + dictWatcher.onDidDelete(() => this.buildInitialData()), + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration("codex-project-manager.validationCount") || + e.affectsConfiguration("codex-project-manager.validationCountAudio") + ) { + this.buildInitialData(); + } + }) ); } diff --git a/types/index.d.ts b/types/index.d.ts index 676bed2f6..24a3cad98 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1604,7 +1604,9 @@ type ProjectManagerMessageFromWebview = | { command: "setGlobalLineNumbers"; } | { command: "getAsrSettings"; } | { command: "saveAsrSettings"; data: { endpoint: string; }; } - | { command: "fetchAsrModels"; data: { endpoint: string; }; }; + | { command: "fetchAsrModels"; data: { endpoint: string; }; } + | { command: "setValidationCountDirect"; data: { count: number; }; } + | { command: "setValidationCountAudioDirect"; data: { count: number; }; }; interface ProjectManagerState { projectOverview: ProjectOverview | null; diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index 66a9c80e9..6b6165e2a 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -30,6 +30,7 @@ import { cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, + shouldExcludeQuillCellFromProgress, } from "@sharedUtils"; import "./TranslationAnimations.css"; import { getVSCodeAPI } from "../shared/vscodeApi"; @@ -894,18 +895,27 @@ const CodexCellEditor: React.FC = () => { [milestoneIndex] ); - // Listen for subsection progress updates + // Listen for subsection progress updates (keeps MilestoneAccordion / ProgressDots in sync) useMessageHandler( "codexCellEditor-subsectionProgress", (event: MessageEvent) => { const message = event.data as EditorReceiveMessages; - if (message?.type === "providerSendsSubsectionProgress") { - // Remove from pending requests - pendingProgressRequestsRef.current.delete(message.milestoneIndex); + if (message?.type !== "providerSendsSubsectionProgress") return; - // Store in cache (handles LRU eviction) - setCachedProgress(message.milestoneIndex, message.subsectionProgress); - } + const idx = message.milestoneIndex; + const progress = message.subsectionProgress; + if (progress == null || typeof idx !== "number") return; + + pendingProgressRequestsRef.current.delete(idx); + + // Update cache (handles LRU eviction) + setCachedProgress(idx, progress); + + // Force state merge so ProgressDots / MilestoneAccordion always re-render with new data + setSubsectionProgress((prev) => ({ + ...prev, + [idx]: progress, + })); }, [setCachedProgress] ); @@ -1774,14 +1784,18 @@ const CodexCellEditor: React.FC = () => { // Calculate progress for each chapter based on translation and validation status const calculateChapterProgress = useCallback( (chapterNum: number): ProgressPercentages => { - // Filter cells for the specific chapter (excluding paratext, milestone, and merged cells) + // Filter cells for the specific chapter (excluding paratext, milestone, merged, and child cells) const cellsForChapter = translationUnits.filter((cell) => { const cellId = cell?.cellMarkers?.[0]; // Exclude milestone cells from progress calculation if (cell.cellType === CodexCellTypes.MILESTONE) { return false; } - if (!cellId || cellId.startsWith("paratext-") || cell.merged) { + if (!cellId || cellId.includes(":paratext-") || cell.merged) { + return false; + } + // Exclude child cells (e.g. type "text" with parentId - they don't count toward progress) + if (cell.metadata?.parentId !== undefined || cell.data?.parentId !== undefined) { return false; } const sectionCellIdParts = cellId.split(" ")?.[1]?.split(":"); @@ -1789,7 +1803,11 @@ const CodexCellEditor: React.FC = () => { return sectionCellNumber === chapterNum.toString(); }); - const totalCells = cellsForChapter.length; + // Only root content cells count (exclude paratext/child for validation too) + const progressCells = cellsForChapter.filter( + (c) => !shouldExcludeQuillCellFromProgress(c) + ); + const totalCells = progressCells.length; if (totalCells === 0) { return { percentTranslationsCompleted: 0, @@ -1801,22 +1819,22 @@ const CodexCellEditor: React.FC = () => { } // Count cells with content (translated) - const cellsWithValues = cellsForChapter.filter( + const cellsWithValues = progressCells.filter( (cell) => cell.cellContent && cell.cellContent.trim().length > 0 && cell.cellContent !== "" ).length; - const cellsWithAudioValues = cellsForChapter.filter((cell) => + const cellsWithAudioValues = progressCells.filter((cell) => cellHasAudioUsingAttachments( (cell as any).attachments, (cell as any).metadata?.selectedAudioId ) ).length; - // Calculate validation data using the same logic as navigation provider - const cellWithValidatedData = cellsForChapter.map((cell) => getCellValueData(cell)); + // Calculate validation data (only from root content cells) + const cellWithValidatedData = progressCells.map((cell) => getCellValueData(cell)); const minimumValidationsRequired = requiredValidations ?? 1; const minimumAudioValidationsRequired = requiredAudioValidations ?? 1; @@ -2040,6 +2058,11 @@ const CodexCellEditor: React.FC = () => { setSaveErrorMessage(null); setSaveRetryCount(0); handleCloseEditor(); + // Refresh subsection progress so MilestoneAccordion / ProgressDots update after content save + const milestoneIdx = currentMilestoneIndexRef.current; + if (milestoneIndex && milestoneIdx < (milestoneIndex.milestones?.length ?? 0)) { + refreshProgressForMilestone(milestoneIdx); + } return; } @@ -2050,7 +2073,7 @@ const CodexCellEditor: React.FC = () => { setSaveErrorMessage(errorMessage); setSaveRetryCount((prev) => prev + 1); }, - [] + [milestoneIndex, refreshProgressForMilestone] ); // State for current user - initialize with a default test username to ensure logic works diff --git a/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx b/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx index 4cc6bdfeb..d95db9088 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx @@ -178,10 +178,7 @@ function processQuillContentForSaving(htmlContent: string): string { // First paragraph becomes a span const spanElement = `${content}`; processedElements.push(spanElement); - debug( - `[processQuillContentForSaving] First paragraph as span:`, - spanElement - ); + debug(`[processQuillContentForSaving] First paragraph as span:`, spanElement); } else { // Subsequent paragraphs remain as paragraphs const pElement = `

${content}

`; @@ -284,10 +281,10 @@ const Editor = forwardRef((props, ref) => { const [footnoteCount, setFootnoteCount] = useState(1); const [characterCount, setCharacterCount] = useState(0); - + // Track the baseline content for dirty checking (updated when LLM content is set) const quillInitialContentRef = useRef(""); - + // Track whether current content is LLM-generated and needs explicit save to be considered "user content" const isLLMContentNeedingApprovalRef = useRef(false); @@ -338,8 +335,8 @@ const Editor = forwardRef((props, ref) => { setSkipOnChange(true); return true; - } - } + }, + }, }, }, spellChecker: {}, @@ -555,42 +552,35 @@ const Editor = forwardRef((props, ref) => { renumberFootnotes(); }, 100); } - // Store initial content when editor is mounted - initialContentRef.current = quill.root.innerHTML; - let isFirstLoad = true; - - // Check if initial content is LLM-generated (needs explicit save) - const checkIfLLMContent = () => { - if (props.editHistory && props.editHistory.length > 0) { - // Get the most recent edit - const latestEdit = props.editHistory[props.editHistory.length - 1]; - // Check if it's an LLM generation or edit (not user edit) - // Only mark as needing approval if it's a preview edit (not already saved) - if (latestEdit.type === EditType.LLM_GENERATION || latestEdit.type === EditType.LLM_EDIT) { - // Only mark as needing approval if this is a preview edit (preview: true) - // Saved edits (addContentToValue: true) don't have preview flag, so they don't need approval - const isPreviewEdit = (latestEdit as any).preview === true; - if (isPreviewEdit) { - isLLMContentNeedingApprovalRef.current = true; - debug("Editor initialized with LLM content needing approval", { latestEdit }); - } else { - // LLM content was already saved, so it doesn't need approval - isLLMContentNeedingApprovalRef.current = false; - debug("Editor initialized with saved LLM content (no approval needed)", { latestEdit }); - } + // Store initial content when editor is mounted (baseline for dirty - never overwrite in handler) + const mountedHtml = quill.root.innerHTML; + initialContentRef.current = mountedHtml; + quillInitialContentRef.current = mountedHtml; + + // Check if initial content is LLM-generated (needs explicit save) at mount + if (props.editHistory && props.editHistory.length > 0) { + const latestEdit = props.editHistory[props.editHistory.length - 1]; + if ( + latestEdit.type === EditType.LLM_GENERATION || + latestEdit.type === EditType.LLM_EDIT + ) { + const isPreviewEdit = (latestEdit as any).preview === true; + if (isPreviewEdit) { + isLLMContentNeedingApprovalRef.current = true; + debug("Editor initialized with LLM content needing approval", { + latestEdit, + }); + } else { + isLLMContentNeedingApprovalRef.current = false; + debug("Editor initialized with saved LLM content (no approval needed)", { + latestEdit, + }); } } - }; + } // Add text-change event listener with source parameter quill.on("text-change", (delta: any, oldDelta: any, source: string) => { - if (isFirstLoad) { - quillInitialContentRef.current = quill.root.innerHTML; - checkIfLLMContent(); // Check on first load - isFirstLoad = false; - return; - } - // Skip on change so the tab key doesn't trigger dirty state logic. if (skipOnChange) { setSkipOnChange(false); @@ -628,26 +618,19 @@ const Editor = forwardRef((props, ref) => { // Normal cell content editing logic (user-initiated changes only) const initialQuillContent = "


"; - let isDirty = false; + const baseline = quillInitialContentRef.current; - // More robust dirty checking - if (quillInitialContentRef.current !== initialQuillContent) { - isDirty = content !== quillInitialContentRef.current; - } else { - // If we started with empty content, any non-empty content is dirty + // Primary: any difference from baseline means dirty (ensures first keystroke always registers) + let isDirty = content !== baseline; + + if (!isDirty && baseline === initialQuillContent) { + // Started empty: any non-empty content is dirty isDirty = !isQuillEmpty(quill) && content !== initialQuillContent; } - - // Additional check: if content is significantly different from initial, it's dirty - if ( - !isDirty && - content && - content !== "


" && - content !== quillInitialContentRef.current - ) { + if (!isDirty && content && content !== "


" && content !== baseline) { isDirty = true; } - + // Critical: If content is LLM-generated and hasn't been explicitly saved by user, // keep it marked as dirty even if it matches baseline if (!isDirty && isLLMContentNeedingApprovalRef.current) { @@ -784,25 +767,25 @@ const Editor = forwardRef((props, ref) => { const editedContent = event.data.content; // Use Quill's API to set content with "api" source (not "user") quill.clipboard.dangerouslyPasteHTML(editedContent, "api"); - + // Update baseline for dirty checking - LLM content is the new "initial" state quillInitialContentRef.current = quill.root.innerHTML; - + // Mark as LLM content needing approval isLLMContentNeedingApprovalRef.current = true; - + // Manually update all state for programmatic changes const textContent = quill.getText(); const charCount = textContent.trim().length; setCharacterCount(charCount); - + // Call onChange with processed content const contentIsEmpty = isQuillEmpty(quill); const finalContent = contentIsEmpty ? "" : processQuillContentForSaving(getCleanedHtml(quill.root.innerHTML)); props.onChange?.({ html: finalContent }); - + // Mark as dirty to ensure save button appears props.onDirtyChange?.(true, quill.root.innerHTML); setUnsavedChanges(true); @@ -814,25 +797,25 @@ const Editor = forwardRef((props, ref) => { if (completionCellId === props.currentLineId) { // Use Quill's API to set content with "api" source (not "user") quill.clipboard.dangerouslyPasteHTML(completionText, "api"); - + // Update baseline for dirty checking - LLM content is the new "initial" state quillInitialContentRef.current = quill.root.innerHTML; - + // Mark as LLM content needing approval isLLMContentNeedingApprovalRef.current = true; - + // Manually update all state for programmatic changes const textContent = quill.getText(); const charCount = textContent.trim().length; setCharacterCount(charCount); - + // Call onChange with processed content const contentIsEmpty = isQuillEmpty(quill); const finalContent = contentIsEmpty ? "" : processQuillContentForSaving(getCleanedHtml(quill.root.innerHTML)); props.onChange?.({ html: finalContent }); - + // Mark as dirty to ensure save button appears props.onDirtyChange?.(true, quill.root.innerHTML); setUnsavedChanges(true); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index d49dc0ccc..5bd7c604f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -261,12 +261,11 @@ export function MilestoneAccordion({ localMilestoneValues, ]); - // Request progress when milestone is expanded + // Request progress when milestone is expanded (if we don't have it yet) useEffect(() => { if (isOpen && expandedMilestone !== null && requestSubsectionProgress) { const milestoneIdx = parseInt(expandedMilestone); if (!isNaN(milestoneIdx)) { - // Check if progress exists for this milestone in allSubsectionProgress const hasProgress = allSubsectionProgress?.[milestoneIdx] !== undefined; if (!hasProgress) { requestSubsectionProgress(milestoneIdx); diff --git a/webviews/codex-webviews/src/CodexCellEditor/utils/progressUtils.ts b/webviews/codex-webviews/src/CodexCellEditor/utils/progressUtils.ts index 2b188f2e2..c0c375008 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/utils/progressUtils.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/utils/progressUtils.ts @@ -55,7 +55,9 @@ export type NormalizedSubsectionProgress = { audioCompletedPercent: number; }; -// Normalize the optional progress fields to percentages for text and audio +// Normalize the optional progress fields to percentages for text and audio. +// When explicit percentages are missing, default to 0 so we never show 100% validation +// unless the backend actually sent that value (avoids false 100% when only paratext/child are validated). export function deriveSubsectionPercentages(progress: { isFullyTranslated: boolean; isFullyValidated: boolean; @@ -65,27 +67,25 @@ export function deriveSubsectionPercentages(progress: { percentAudioValidatedTranslations?: number; }): NormalizedSubsectionProgress { const textValidatedPercent = - (progress as any).percentTextValidatedTranslations !== undefined - ? (progress as any).percentTextValidatedTranslations - : progress.isFullyValidated - ? 100 - : 0; + (progress as { percentTextValidatedTranslations?: number; }).percentTextValidatedTranslations !== undefined + ? (progress as { percentTextValidatedTranslations: number; }).percentTextValidatedTranslations + : 0; const textCompletedPercent = - (progress as any).percentTranslationsCompleted !== undefined - ? (progress as any).percentTranslationsCompleted + (progress as { percentTranslationsCompleted?: number; }).percentTranslationsCompleted !== undefined + ? (progress as { percentTranslationsCompleted: number; }).percentTranslationsCompleted : progress.isFullyTranslated ? 100 : 0; const audioValidatedPercent = - (progress as any).percentAudioValidatedTranslations !== undefined - ? (progress as any).percentAudioValidatedTranslations + (progress as { percentAudioValidatedTranslations?: number; }).percentAudioValidatedTranslations !== undefined + ? (progress as { percentAudioValidatedTranslations: number; }).percentAudioValidatedTranslations : 0; const audioCompletedPercent = - (progress as any).percentAudioTranslationsCompleted !== undefined - ? (progress as any).percentAudioTranslationsCompleted + (progress as { percentAudioTranslationsCompleted?: number; }).percentAudioTranslationsCompleted !== undefined + ? (progress as { percentAudioTranslationsCompleted: number; }).percentAudioTranslationsCompleted : 0; return { diff --git a/webviews/codex-webviews/src/CommentsView/CommentsView.tsx b/webviews/codex-webviews/src/CommentsView/CommentsView.tsx index 3f635a80b..4841e8dc6 100644 --- a/webviews/codex-webviews/src/CommentsView/CommentsView.tsx +++ b/webviews/codex-webviews/src/CommentsView/CommentsView.tsx @@ -1,28 +1,21 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { MessageSquare, - Search, Plus, - ChevronRight, + ChevronLeft, ChevronDown, - Edit, + ChevronRight, Check, - Circle, X, Trash2, Undo2, Send, - Reply, - Eye, - EyeOff, - ChevronUp, + Hash, Clock, - MoreHorizontal, + Reply, } from "lucide-react"; import { Button } from "../components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Badge } from "../components/ui/badge"; -import { Input } from "../components/ui/input"; import { NotebookCommentThread, CommentPostMessages, CellIdGlobalState } from "../../../../types"; import { v4 as uuidv4 } from "uuid"; import { WebviewHeader } from "../components/WebviewHeader"; @@ -31,138 +24,66 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../com const vscode = acquireVsCodeApi(); type Comment = NotebookCommentThread["comments"][0]; -interface UserAvatar { - username: string; - email?: string; - size?: "small" | "medium" | "large"; -} - // Helper function to generate deterministic colors for usernames const getUserColor = (username: string): string => { - // Distinct, readable colors for avatars (using actual color values) const colors = [ - "#3b82f6", // blue-500 - "#10b981", // emerald-500 - "#8b5cf6", // violet-500 - "#f59e0b", // amber-500 - "#ec4899", // pink-500 - "#06b6d4", // cyan-500 - "#ef4444", // red-500 - "#6366f1", // indigo-500 - "#14b8a6", // teal-500 - "#84cc16", // lime-500 - "#f97316", // orange-500 - "#a855f7", // purple-500 - "#f43f5e", // rose-500 - "#0ea5e9", // sky-500 - "#22c55e", // green-500 - "#eab308", // yellow-500 + "#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ec4899", "#06b6d4", + "#ef4444", "#6366f1", "#14b8a6", "#84cc16", "#f97316", "#a855f7", ]; - - // Create deterministic hash from username using a better hash function let hash = 0; - if (username.length === 0) return colors[0]; - for (let i = 0; i < username.length; i++) { - const char = username.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + hash = (hash << 5) - hash + username.charCodeAt(i); + hash = hash & hash; } - - // Add some additional mixing to reduce collisions - hash = hash ^ (hash >>> 16); - hash = hash * 0x85ebca6b; - hash = hash ^ (hash >>> 13); - hash = hash * 0xc2b2ae35; - hash = hash ^ (hash >>> 16); - - // Use absolute value and modulo to get color index - const colorIndex = Math.abs(hash) % colors.length; - return colors[colorIndex]; + return colors[Math.abs(hash) % colors.length]; }; -// Helper function to format timestamps in a user-friendly way +// Helper function to format timestamps const formatTimestamp = (timestamp: string | number): { display: string; full: string } => { const now = new Date(); const date = new Date(typeof timestamp === "string" ? parseInt(timestamp) : timestamp); - - // If invalid date, return fallback - if (isNaN(date.getTime())) { - return { display: "", full: "" }; - } + if (isNaN(date.getTime())) return { display: "", full: "" }; const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - // Full timestamp for hover const full = date.toLocaleString(); - // Display format based on age - if (diffMinutes < 1) { - return { display: "just now", full }; - } else if (diffMinutes < 60) { - return { display: `${diffMinutes}m ago`, full }; - } else if (diffHours < 24) { - return { display: `${diffHours}h ago`, full }; - } else if (diffDays === 1) { - return { display: "yesterday", full }; - } else if (diffDays < 7) { - return { display: `${diffDays}d ago`, full }; - } else { - // For older dates, show month/day - const monthDay = date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - return { display: monthDay, full }; - } + if (diffMinutes < 1) return { display: "just now", full }; + if (diffMinutes < 60) return { display: `${diffMinutes}m ago`, full }; + if (diffHours < 24) return { display: `${diffHours}h ago`, full }; + if (diffDays === 1) return { display: "yesterday", full }; + if (diffDays < 7) return { display: `${diffDays}d ago`, full }; + return { display: date.toLocaleDateString("en-US", { month: "short", day: "numeric" }), full }; }; -const UserAvatar = ({ username, email, size = "small" }: UserAvatar) => { - const sizeMap = { - small: { width: "24px", height: "24px", fontSize: "12px" }, - medium: { width: "32px", height: "32px", fontSize: "14px" }, - large: { width: "40px", height: "40px", fontSize: "16px" }, - }; - - const userColor = getUserColor(username); - - return ( -
-
- {username[0].toUpperCase()} -
- {/* Hide username text on narrow viewports (VSCode sidebar) */} -
- {username} -
-
- ); -}; +// Author name with color +const AuthorName = ({ username, size = "sm" }: { username: string; size?: "sm" | "base" }) => ( + + {username} + +); function App() { - const [cellId, setCellId] = useState({ cellId: "", uri: "" }); - const [uri, setUri] = useState(); + const [cellId, setCellId] = useState({ cellId: "", uri: "", globalReferences: [] }); const [commentThreadArray, setCommentThread] = useState([]); - const [replyText, setReplyText] = useState>({}); - const [collapsedThreads, setCollapsedThreads] = useState>({}); - const [searchQuery, setSearchQuery] = useState(""); - const [showNewCommentForm, setShowNewCommentForm] = useState(false); - const [newCommentText, setNewCommentText] = useState(""); + const [messageText, setMessageText] = useState(""); + const [selectedThread, setSelectedThread] = useState(null); const [pendingResolveThreads, setPendingResolveThreads] = useState>(new Set()); - const [viewMode, setViewMode] = useState<"all" | "cell">("cell"); - const [showResolvedThreads, setShowResolvedThreads] = useState(false); + const [newThreadText, setNewThreadText] = useState(""); + const [currentSectionExpanded, setCurrentSectionExpanded] = useState(true); + const [allSectionExpanded, setAllSectionExpanded] = useState(false); + const newThreadRef = useRef(null); + const [replyingTo, setReplyingTo] = useState(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const commentRefs = useRef>(new Map()); + const MAX_MESSAGE_LENGTH = 8000; + const REPLY_PREVIEW_MAX_WORDS = 12; const [currentUser, setCurrentUser] = useState<{ username: string; email: string; @@ -173,1032 +94,662 @@ function App() { isAuthenticated: false, }); - // Force re-render for timestamp updates - const [timestampUpdateTrigger, setTimestampUpdateTrigger] = useState(0); - - // Update timestamps every minute + // Scroll to bottom when messages change useEffect(() => { - const interval = setInterval(() => { - setTimestampUpdateTrigger((prev) => prev + 1); - }, 60000); // Update every minute - - return () => clearInterval(interval); + if (selectedThread) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [selectedThread, commentThreadArray]); + + // Auto-resize textarea + const autoResizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"; + } }, []); - // Track current user state changes useEffect(() => { - // User state updated - }, [currentUser]); + autoResizeTextarea(); + }, [messageText, autoResizeTextarea]); - const [expandedThreads, setExpandedThreads] = useState>(new Set()); - const [replyingTo, setReplyingTo] = useState<{ threadId: string; username?: string } | null>( - null - ); - const [editingTitle, setEditingTitle] = useState(null); - const [threadTitleEdit, setThreadTitleEdit] = useState(""); - - // Helper function to determine if thread is currently resolved based on latest event const isThreadResolved = useCallback((thread: NotebookCommentThread): boolean => { const resolvedEvents = thread.resolvedEvent || []; - const latestResolvedEvent = - resolvedEvents.length > 0 - ? resolvedEvents.reduce((latest, event) => - event.timestamp > latest.timestamp ? event : latest - ) - : null; - return latestResolvedEvent?.resolved || false; + if (resolvedEvents.length === 0) return false; + const latest = resolvedEvents.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); + return latest.resolved || false; }, []); - // Helper function to determine if thread is currently deleted based on latest event const isThreadDeleted = useCallback((thread: NotebookCommentThread): boolean => { const deletionEvents = thread.deletionEvent || []; - const latestDeletionEvent = - deletionEvents.length > 0 - ? deletionEvents.reduce((latest, event) => - event.timestamp > latest.timestamp ? event : latest - ) - : null; - return latestDeletionEvent?.deleted || false; + if (deletionEvents.length === 0) return false; + const latest = deletionEvents.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); + return latest.deleted || false; }, []); const handleMessage = useCallback( (event: MessageEvent) => { const message: CommentPostMessages = event.data; - switch (message.command) { - case "commentsFromWorkspace": { + case "commentsFromWorkspace": if (message.content) { try { - const comments = JSON.parse(message.content); - setCommentThread(comments); + setCommentThread(JSON.parse(message.content)); setPendingResolveThreads(new Set()); } catch (error) { console.error("[CommentsWebview] Error parsing comments:", error); } } break; - } - case "reload": { + case "reload": if (message.data?.cellId) { - setCellId({ cellId: message.data.cellId, uri: message.data.uri || "" }); - if (viewMode === "cell") { - setSearchQuery(message.data.cellId); - } - } - if (message.data?.uri) { - setUri(message.data.uri); + setCellId({ + cellId: message.data.cellId, + uri: message.data.uri || "", + globalReferences: message.data.globalReferences || [], + }); } break; - } - case "updateUserInfo": { - if (message.userInfo) { - const newUser = { - username: message.userInfo.username, - email: message.userInfo.email, - isAuthenticated: true, - }; - setCurrentUser(newUser); - } else { - const newUser = { - username: "vscode", - email: "", - isAuthenticated: false, - }; - setCurrentUser(newUser); - } + case "updateUserInfo": + setCurrentUser( + message.userInfo + ? { ...message.userInfo, isAuthenticated: true } + : { username: "vscode", email: "", isAuthenticated: false } + ); break; - } - default: - // Unknown message command } }, - [viewMode] + [] ); useEffect(() => { window.addEventListener("message", handleMessage); + vscode.postMessage({ command: "fetchComments" }); + vscode.postMessage({ command: "getCurrentCellId" }); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); - // Request initial data - vscode.postMessage({ - command: "fetchComments", - } as CommentPostMessages); + // Parse reply reference from message body + const parseReplyInfo = (body: string): { replyToId: string | null; content: string } => { + const match = body.match(/^@reply:([^\n]+)\n([\s\S]*)$/); + if (match) { + return { replyToId: match[1], content: match[2] }; + } + // Legacy: check for markdown quote style + const lines = body.split("\n"); + const quoteLines: string[] = []; + let contentStart = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("> ")) { + quoteLines.push(lines[i].slice(2)); + } else if (lines[i].trim() === "" && quoteLines.length > 0) { + contentStart = i + 1; + break; + } else { + break; + } + } + if (quoteLines.length > 0) { + return { replyToId: null, content: lines.slice(contentStart).join("\n") }; + } + return { replyToId: null, content: body }; + }; - vscode.postMessage({ - command: "getCurrentCellId", - } as CommentPostMessages); + // Find comment by ID in current thread + const findCommentById = (commentId: string): Comment | null => { + if (!currentThread) return null; + return currentThread.comments.find((c) => c.id === commentId) || null; + }; - return () => { - window.removeEventListener("message", handleMessage); - }; - }, [handleMessage]); + // Scroll to a comment + const scrollToComment = (commentId: string) => { + const element = commentRefs.current.get(commentId); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + element.classList.add("bg-primary/10"); + setTimeout(() => element.classList.remove("bg-primary/10"), 1500); + } + }; + + // Truncate text to max words + const truncateToWords = (text: string, maxWords: number): string => { + const words = text.split(/\s+/); + if (words.length <= maxWords) return text; + return words.slice(0, maxWords).join(" ") + "..."; + }; + + const getCellLabel = (cellIdState: CellIdGlobalState | string): string => { + if (typeof cellIdState === "string") { + return cellIdState.length > 20 ? cellIdState.slice(-12) : cellIdState; + } + if (cellIdState.globalReferences?.length > 0) { + return cellIdState.globalReferences[0]; + } + const id = cellIdState.cellId; + return id.length > 20 ? id.slice(-12) : id; + }; + + const getThreadPreview = (thread: NotebookCommentThread): string => { + const firstComment = thread.comments[0]; + if (!firstComment) return "Empty thread"; + const plainText = firstComment.body + .split("\n") + .filter((line) => !line.startsWith("> ")) + .join(" ") + .trim(); + return plainText.length > 50 ? plainText.slice(0, 47) + "..." : plainText || "Empty thread"; + }; + + // Sort function for threads + const byLatestActivity = (a: NotebookCommentThread, b: NotebookCommentThread) => { + const aTime = Math.max(...a.comments.map((c) => c.timestamp)); + const bTime = Math.max(...b.comments.map((c) => c.timestamp)); + return bTime - aTime; + }; + + // Sort threads: unresolved first, then resolved + const sortThreads = (threads: NotebookCommentThread[]) => { + const unresolved = threads.filter((t) => !isThreadResolved(t)); + const resolved = threads.filter((t) => isThreadResolved(t)); + return [...unresolved.sort(byLatestActivity), ...resolved.sort(byLatestActivity)]; + }; + + // Threads for current cell + const currentCellThreads = useMemo(() => { + const nonDeleted = commentThreadArray.filter((t) => !isThreadDeleted(t)); + const filtered = nonDeleted.filter((t) => cellId.cellId && t.cellId.cellId === cellId.cellId); + return sortThreads(filtered); + }, [commentThreadArray, cellId.cellId, isThreadDeleted, isThreadResolved]); + + // All threads (excluding current cell to avoid duplicates) + const allOtherThreads = useMemo(() => { + const nonDeleted = commentThreadArray.filter((t) => !isThreadDeleted(t)); + const filtered = nonDeleted.filter((t) => !cellId.cellId || t.cellId.cellId !== cellId.cellId); + return sortThreads(filtered); + }, [commentThreadArray, cellId.cellId, isThreadDeleted, isThreadResolved]); - const handleReply = (threadId: string) => { - if (!replyText[threadId]?.trim() || !currentUser.isAuthenticated) return; + const currentThread = selectedThread + ? commentThreadArray.find((t) => t.id === selectedThread) + : null; + + const handleSendMessage = () => { + if (!messageText.trim() || !currentThread || !currentUser.isAuthenticated) return; + if (isThreadResolved(currentThread)) return; - const existingThread = commentThreadArray.find((thread) => thread.id === threadId); const timestamp = Date.now(); - const newCommentId = `${timestamp}-${Math.random().toString(36).substr(2, 9)}`; - const comment: Comment = { - id: newCommentId, - timestamp: timestamp, - body: replyText[threadId], + // Build message body with optional reply reference + let body = messageText.trim(); + if (replyingTo) { + body = `@reply:${replyingTo.id}\n${body}`; + } + + const newComment: Comment = { + id: `${timestamp}-${Math.random().toString(36).substr(2, 9)}`, + timestamp, + body, mode: 1, author: { name: currentUser.username }, deleted: false, }; - const updatedThread: NotebookCommentThread = { - ...(existingThread || { - id: threadId, - canReply: true, - cellId: cellId, - collapsibleState: 0, - threadTitle: "", - deleted: false, - resolved: false, - }), - comments: existingThread ? [...existingThread.comments, comment] : [comment], - }; - vscode.postMessage({ command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - - setReplyText((prev) => ({ ...prev, [threadId]: "" })); + commentThread: { ...currentThread, comments: [...currentThread.comments, newComment] }, + }); + setMessageText(""); setReplyingTo(null); }; - const handleThreadDeletion = (commentThreadId: string) => { - vscode.postMessage({ - command: "deleteCommentThread", - commentThreadId, - } as CommentPostMessages); - }; - - const handleCommentDeletion = (commentId: string, commentThreadId: string) => { - vscode.postMessage({ - command: "deleteComment", - args: { commentId, commentThreadId }, - } as CommentPostMessages); - }; - - const handleUndoCommentDeletion = (commentId: string, commentThreadId: string) => { - vscode.postMessage({ - command: "undoCommentDeletion", - args: { commentId, commentThreadId }, - } as CommentPostMessages); - }; + const handleCreateThread = () => { + if (!newThreadText.trim() || !cellId.cellId || !currentUser.isAuthenticated) return; - const handleNewComment = () => { - if (!newCommentText.trim() || !cellId.cellId || !currentUser.isAuthenticated) return; - - // Generate a timestamp for the default title - const now = new Date(); - const defaultTitle = now.toLocaleString(); const timestamp = Date.now(); - const commentId = `${timestamp}-${Math.random().toString(36).substr(2, 9)}`; - const newThread: NotebookCommentThread = { id: uuidv4(), canReply: true, cellId: cellId, collapsibleState: 0, - threadTitle: defaultTitle, + threadTitle: new Date().toLocaleString(), deletionEvent: [], resolvedEvent: [], - comments: [ - { - id: commentId, - timestamp: timestamp, - body: newCommentText.trim(), - mode: 1, - author: { name: currentUser.username }, - deleted: false, - }, - ], - }; - - vscode.postMessage({ - command: "updateCommentThread", - commentThread: newThread, - } as CommentPostMessages); - - setNewCommentText(""); - setShowNewCommentForm(false); - }; - - const handleEditThreadTitle = (threadId: string) => { - if (!threadTitleEdit.trim()) return; - - const existingThread = commentThreadArray.find((thread) => thread.id === threadId); - if (!existingThread) return; - - const updatedThread = { - ...existingThread, - threadTitle: threadTitleEdit.trim(), + comments: [{ + id: `${timestamp}-${Math.random().toString(36).substr(2, 9)}`, + timestamp, + body: newThreadText.trim(), + mode: 1, + author: { name: currentUser.username }, + deleted: false, + }], }; - vscode.postMessage({ - command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - - setEditingTitle(null); - setThreadTitleEdit(""); + vscode.postMessage({ command: "updateCommentThread", commentThread: newThread }); + setNewThreadText(""); + setSelectedThread(newThread.id); }; const toggleResolved = (thread: NotebookCommentThread) => { - setPendingResolveThreads((prev) => { - const next = new Set(prev); - next.add(thread.id); - return next; - }); - - // Determine if thread is currently resolved (latest event determines state) + setPendingResolveThreads((prev) => new Set(prev).add(thread.id)); const isCurrentlyResolved = isThreadResolved(thread); - // Add new event with opposite state and current timestamp - const updatedThread = { - ...thread, - resolvedEvent: [ - ...(thread.resolvedEvent || []), - { - timestamp: Date.now(), - author: { name: currentUser?.username || "Unknown" }, - resolved: !isCurrentlyResolved, - }, - ], - comments: [...thread.comments], - }; - vscode.postMessage({ command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - }; - - const toggleCollapsed = (threadId: string) => { - setCollapsedThreads((prev) => ({ - ...prev, - [threadId]: !prev[threadId], - })); - }; - - const toggleAllThreads = (collapse: boolean) => { - const newState: Record = {}; - filteredCommentThreads.forEach((thread) => { - newState[thread.id] = collapse; + commentThread: { + ...thread, + resolvedEvent: [ + ...(thread.resolvedEvent || []), + { timestamp: Date.now(), author: { name: currentUser.username }, resolved: !isCurrentlyResolved }, + ], + }, }); - setCollapsedThreads(newState); }; - const getCellId = (cellId: string) => { - const parts = cellId.split(":"); - const finalPart = parts[parts.length - 1] || cellId; - // Show full cell ID if it's less than 10 characters - return cellId.length < 10 ? cellId : finalPart; + const handleDeleteComment = (commentId: string, threadId: string) => { + vscode.postMessage({ command: "deleteComment", args: { commentId, commentThreadId: threadId } }); }; - const filteredCommentThreads = useMemo(() => { - // First, get all non-deleted threads - const nonDeletedThreads = commentThreadArray.filter((thread) => !isThreadDeleted(thread)); - - // Then, apply additional filtering based on view mode, search, and resolved status - const filtered = nonDeletedThreads.filter((commentThread) => { - // Skip resolved threads if they're hidden - if (!showResolvedThreads && isThreadResolved(commentThread)) return false; - - // If in cell view mode, only show comments for the current cell - if (viewMode === "cell" && cellId.cellId) { - return commentThread.cellId.cellId === cellId.cellId; - } - - // If searching, filter by search query - if (searchQuery) { - return ( - commentThread.threadTitle?.toLowerCase().includes(searchQuery.toLowerCase()) || - commentThread.comments.some((comment) => - comment.body.toLowerCase().includes(searchQuery.toLowerCase()) - ) || - commentThread.cellId.cellId.toLowerCase().includes(searchQuery.toLowerCase()) - ); - } - - // In all view mode with no search, show all comments (except resolved ones if hidden) - return true; - }); - - // Sort threads by newest first (based on latest comment timestamp) - return filtered.sort((a, b) => { - const getLatestTimestamp = (thread: NotebookCommentThread) => { - const timestamps = thread.comments.map((c) => c.timestamp); - return Math.max(...timestamps); - }; - return getLatestTimestamp(b) - getLatestTimestamp(a); - }); - }, [commentThreadArray, searchQuery, viewMode, cellId.cellId, showResolvedThreads]); - - // Count of hidden resolved threads - const hiddenResolvedThreadsCount = useMemo(() => { - if (showResolvedThreads) return 0; - - const nonDeletedThreads = commentThreadArray.filter((thread) => !isThreadDeleted(thread)); - - return nonDeletedThreads.filter((thread) => { - const isResolved = isThreadResolved(thread); - const matchesCurrentCell = - viewMode !== "cell" || thread.cellId.cellId === cellId.cellId; - const matchesSearch = - !searchQuery || - thread.threadTitle?.toLowerCase().includes(searchQuery.toLowerCase()) || - thread.comments.some((comment) => - comment.body.toLowerCase().includes(searchQuery.toLowerCase()) - ) || - thread.cellId.cellId.toLowerCase().includes(searchQuery.toLowerCase()); - - return isResolved && matchesCurrentCell && matchesSearch; - }).length; - }, [commentThreadArray, viewMode, cellId.cellId, searchQuery, showResolvedThreads]); - - // Whether a user can start a new top-level comment thread (requires auth and active cell) - const canStartNewComment = currentUser.isAuthenticated && Boolean(cellId.cellId); - - // Helper function to render comment body with blockquotes - const renderCommentBody = (body: string) => { - if (!body) return null; - - const lines = body.split("\n"); - const elements: JSX.Element[] = []; - let currentQuoteLines: string[] = []; - - const flushQuote = () => { - if (currentQuoteLines.length > 0) { - elements.push( -
- {currentQuoteLines.join("\n")} -
- ); - currentQuoteLines = []; - } - }; - - lines.forEach((line, index) => { - if (line.startsWith("> ")) { - currentQuoteLines.push(line.substring(2)); - } else { - flushQuote(); - if (line.trim() || index < lines.length - 1) { - elements.push( - - {line} - {index < lines.length - 1 &&
} -
- ); - } - } - }); - - flushQuote(); - return elements; + const handleUndoDelete = (commentId: string, threadId: string) => { + vscode.postMessage({ command: "undoCommentDeletion", args: { commentId, commentThreadId: threadId } }); }; - const handleReplyToComment = (comment: Comment, threadId: string) => { - const quotedText = `> ${comment.body.replace(/\n/g, "\n> ")}\n\n`; - setReplyText((prev) => ({ - ...prev, - [threadId]: quotedText, - })); - setReplyingTo({ threadId, username: comment.author.name }); + // Render message content (without reply prefix) + const renderMessageContent = (content: string) => { + return content.split("\n").map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ )); }; - const CommentCard = ({ - thread, - comment, - }: { - thread: NotebookCommentThread; - comment: Comment; - }) => { - const formattedTime = formatTimestamp(comment.timestamp); - const [isHovered, setIsHovered] = useState(false); + // Render a thread item + const renderThreadItem = (thread: NotebookCommentThread) => { + const resolved = isThreadResolved(thread); + const latestComment = thread.comments[thread.comments.length - 1]; + const time = formatTimestamp(latestComment?.timestamp || 0); return (
setSelectedThread(thread.id)} + className={`px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors border-l-2 ${ + resolved + ? "border-transparent opacity-60" + : "border-transparent hover:border-primary" }`} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > -
- - - - - {comment.author.name} - - - {/* Comment content */} -
-
- - - - {formattedTime.display} - - - {formattedTime.full} - -
-
- {comment.deleted - ? "This comment has been deleted" - : renderCommentBody(comment.body)} -
-
+
+ + + {getThreadPreview(thread)} + + {resolved && ( + + + + )} +
+
+ {thread.comments.length} {thread.comments.length === 1 ? "message" : "messages"} + • + {time.display}
- - {/* Action buttons - positioned at bottom right */} - {isHovered && currentUser.isAuthenticated && !comment.deleted && ( -
- - - {comment.author.name === currentUser.username && ( - - )} -
- )} - - {/* Undo deletion button for deleted comments - only show on hover */} - {comment.deleted && comment.author.name === currentUser.username && isHovered && ( -
- -
- )}
); }; - return ( - -
- - - {/* Header */} -
- {/* {currentUser.isAuthenticated && ( -
- -
- )} */} - - {/* View mode selector */} -
- - -
- - {/* Search - - // TODO: this should be a react select for autocomplete of cell ids or allow you to search text - */} -
-
- - setSearchQuery(e.target.value)} - disabled={viewMode === "cell"} - /> -
-
- -
-
- - - {filteredCommentThreads.length}{" "} - {filteredCommentThreads.length === 1 ? "thread" : "threads"} - -
- - {currentUser.isAuthenticated && - (canStartNewComment ? ( - - ) : ( - - - {/* span wrapper so tooltip works with disabled button */} - - - - - - Please select a cell to comment on first - - - ))} -
-
- - {/* New comment form */} - {showNewCommentForm && ( - - -
- - New comment - {viewMode === "cell" && ( - - on {getCellId(cellId.cellId)} - - )} -
-
- -
- { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleNewComment(); - } - }} - onChange={(e) => setNewCommentText(e.target.value)} - /> -
- + // Thread list view with collapsible sections + const ThreadList = () => ( +
+
+ {/* Current Cell Section */} +
+ {/* Section header */} + + + {/* Section content */} + {currentSectionExpanded && ( +
+ {/* Inline new thread input */} + {currentUser.isAuthenticated && cellId.cellId && ( +
+
+