From 9c012f8b9b3c0ac31594c1e3d331d7a98ac380fb Mon Sep 17 00:00:00 2001 From: Erik Vank Date: Mon, 12 May 2025 09:28:51 -0700 Subject: [PATCH 1/4] feat: initial --- .gitignore | 3 +- vscode-extension/src/AnnotationTracker.ts | 159 +++++- .../src/panels/AnnotationManagerPanel.ts | 194 +++++-- vscode-extension/src/server/retag.ts | 477 +++++++++++++----- 4 files changed, 651 insertions(+), 182 deletions(-) diff --git a/.gitignore b/.gitignore index 0bab480..2e2fe19 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ codetations-react/src/applications/src/config.js **/.vscode/settings.json # Annotations for testing -codetations/ \ No newline at end of file +codetations/ +**/.claude/settings.local.json diff --git a/vscode-extension/src/AnnotationTracker.ts b/vscode-extension/src/AnnotationTracker.ts index 64dd73b..9e7d453 100644 --- a/vscode-extension/src/AnnotationTracker.ts +++ b/vscode-extension/src/AnnotationTracker.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; -import { createPatch, applyPatch } from "diff"; +import { createPatch, applyPatch, parsePatch } from "diff"; import { AnnotationManagerPanel } from "./panels/AnnotationManagerPanel"; export interface Annotation { // HACK this just duplicates "../webview-ui/src/Annotation.tsx" @@ -29,12 +29,83 @@ export class AnnotationTracker implements vscode.Disposable { private documentAnnotations: Map = new Map(); private decorationTypes: Map = new Map(); private fileChangeTimers: Map = new Map(); - + // Store the document content cache private documentContentCache = new Map(); - + // Track accumulated changes for proper position updates private pendingChanges = new Map(); + + /** + * Logging levels for diff compression + */ + private readonly LOG_LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3 + }; + + /** + * Current log level (can be adjusted based on configuration) + */ + private logLevel = this.LOG_LEVELS.WARN; + + /** + * Log a message with the specified level + * @param level The log level + * @param message The message to log + * @param args Additional arguments to log + */ + private log(level: number, message: string, ...args: any[]): void { + if (level >= this.logLevel) { + switch (level) { + case this.LOG_LEVELS.DEBUG: + console.debug(`[DIFF] ${message}`, ...args); + break; + case this.LOG_LEVELS.INFO: + console.log(`[DIFF] ${message}`, ...args); + break; + case this.LOG_LEVELS.WARN: + console.warn(`[DIFF] ${message}`, ...args); + break; + case this.LOG_LEVELS.ERROR: + console.error(`[DIFF] ${message}`, ...args); + break; + } + } + } + + /** + * Validate a diff patch to ensure it can be safely applied + * @param source The source text + * @param patch The patch to validate + * @returns True if the patch is valid and can be applied, false otherwise + */ + private validatePatch(source: string, patch: string): boolean { + try { + // Check if patch is valid by parsing it + const parsedPatches = parsePatch(patch); + if (!parsedPatches || parsedPatches.length === 0) { + this.log(this.LOG_LEVELS.WARN, "Invalid patch: no patches found"); + return false; + } + + // Check patch structure + this.log(this.LOG_LEVELS.DEBUG, "Validating patch with hunks:", parsedPatches.map(p => p.hunks.length).reduce((a, b) => a + b, 0)); + + // Check if patch can be applied successfully + const result = applyPatch(source, patch); + const isValid = result !== false && result !== null; + if (!isValid) { + this.log(this.LOG_LEVELS.WARN, "Patch application failed"); + } + return isValid; + } catch (e) { + this.log(this.LOG_LEVELS.ERROR, "Error validating patch:", e); + return false; + } + } constructor(private context: vscode.ExtensionContext) { // Setup buffer change listeners @@ -55,8 +126,7 @@ export class AnnotationTracker implements vscode.Disposable { public async loadAnnotationsForDocument(document: vscode.TextDocument): Promise { const documentKey = document.uri.toString(); const annotationsUri = this.getAnnotationsFilePath(document.uri.fsPath); - console.debug(`Loading annotations for ${documentKey} from ${annotationsUri}`); - // TODO we have only sort-of validated that diff loading works correctly. + this.log(this.LOG_LEVELS.INFO, `Loading annotations for ${documentKey} from ${annotationsUri}`); if (this.documentAnnotations.has(documentKey)) { // Already loaded return this.documentAnnotations.get(documentKey) as Annotation[]; @@ -69,14 +139,54 @@ export class AnnotationTracker implements vscode.Disposable { // Reconstruct each annotation's document field using applyPatch const reconstructedAnnotations = state.annotations.map(ann => { - const reconstructedDoc = - ann.documentDiff ? - applyPatch(state.document, ann.documentDiff) || state.document - : ann.document || state.document; - const originalDoc = - ann.original.documentDiff ? - applyPatch(state.document, ann.original.documentDiff) || reconstructedDoc - : ann.original.document; + // Process document diff + let reconstructedDoc = ann.document || state.document; + if (ann.documentDiff) { + try { + // First validate the patch + if (this.validatePatch(state.document, ann.documentDiff)) { + const patchResult = applyPatch(state.document, ann.documentDiff); + if (patchResult !== false && patchResult !== null) { + reconstructedDoc = patchResult; + } else { + this.log(this.LOG_LEVELS.WARN, `Failed to apply document diff for annotation ${ann.id}. Using fallback.`); + // Keep using the fallback document if provided, otherwise use state.document + reconstructedDoc = ann.document || state.document; + } + } else { + this.log(this.LOG_LEVELS.WARN, `Invalid document diff for annotation ${ann.id}. Using fallback.`); + reconstructedDoc = ann.document || state.document; + } + } catch (e) { + this.log(this.LOG_LEVELS.ERROR, `Error applying document diff for annotation ${ann.id}:`, e); + reconstructedDoc = ann.document || state.document; + } + } + + // Process original document diff + let originalDoc = ann.original.document || reconstructedDoc; + if (ann.original.documentDiff) { + try { + // First validate the patch + if (this.validatePatch(state.document, ann.original.documentDiff)) { + const patchResult = applyPatch(state.document, ann.original.documentDiff); + if (patchResult !== false && patchResult !== null) { + originalDoc = patchResult; + } else { + this.log(this.LOG_LEVELS.WARN, `Failed to apply original document diff for annotation ${ann.id}. Using fallback.`); + // Keep using the fallback original document if provided, otherwise use reconstructedDoc + originalDoc = ann.original.document || reconstructedDoc; + } + } else { + this.log(this.LOG_LEVELS.WARN, `Invalid original document diff for annotation ${ann.id}. Using fallback.`); + originalDoc = ann.original.document || reconstructedDoc; + } + } catch (e) { + this.log(this.LOG_LEVELS.ERROR, `Error applying original document diff for annotation ${ann.id}:`, e); + originalDoc = ann.original.document || reconstructedDoc; + } + } + return { ...ann, document: reconstructedDoc, @@ -103,7 +213,7 @@ export class AnnotationTracker implements vscode.Disposable { return []; } } catch (error) { - console.error(`Error loading annotations for ${documentKey}:`, error); + this.log(this.LOG_LEVELS.ERROR, `Error loading annotations for ${documentKey}:`, error); this.documentAnnotations.set(documentKey, []); return []; } @@ -128,10 +238,11 @@ export class AnnotationTracker implements vscode.Disposable { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - console.debug(`Saving annotations for ${documentKey} to ${annotationsUri}`); + this.log(this.LOG_LEVELS.INFO, `Saving annotations for ${documentKey} to ${annotationsUri}`); - // Save the global document string - const globalDocument = annotations.length > 0 ? annotations[0].document : document.getText(); + // Get the current document text as the global reference + // Always use the current editor document text to ensure consistency + const globalDocument = document.getText(); // Write annotations to file with documentDiffs const state = { @@ -144,9 +255,14 @@ export class AnnotationTracker implements vscode.Disposable { if (annDoc !== globalDocument) { try { documentDiff = createPatch(document.fileName, globalDocument, annDoc); + // Use our validation method to check if the patch is valid + if (!this.validatePatch(globalDocument, documentDiff)) { + this.log(this.LOG_LEVELS.WARN, `Created patch for annotation ${ann.id} could not be validated. Using full document content instead.`); + documentDiff = undefined; + } } catch (e) { documentDiff = undefined; - console.error("Error creating document diff:", e); + this.log(this.LOG_LEVELS.ERROR, `Error creating document diff for annotation ${ann.id}:`, e); } } const { original: { @@ -157,9 +273,14 @@ export class AnnotationTracker implements vscode.Disposable { if (originalDocument !== globalDocument) { try { originalDocumentDiff = createPatch(document.fileName, globalDocument, originalDocument); + // Use our validation method to check if the patch is valid + if (!this.validatePatch(globalDocument, originalDocumentDiff)) { + this.log(this.LOG_LEVELS.WARN, `Created original document patch for annotation ${ann.id} could not be validated. Using full document content instead.`); + originalDocumentDiff = undefined; + } } catch (e) { originalDocumentDiff = undefined; - console.error("Error creating original document diff:", e); + this.log(this.LOG_LEVELS.ERROR, `Error creating original document diff for annotation ${ann.id}:`, e); } } return { diff --git a/vscode-extension/src/panels/AnnotationManagerPanel.ts b/vscode-extension/src/panels/AnnotationManagerPanel.ts index 61dd774..5518fa1 100644 --- a/vscode-extension/src/panels/AnnotationManagerPanel.ts +++ b/vscode-extension/src/panels/AnnotationManagerPanel.ts @@ -433,8 +433,24 @@ export class SidebarProvider implements vscode.WebviewViewProvider { const { codeWithSnippetDelimited, delimiter } = this._preprocessAnnotation(annotation); try { - // Get the API key from settings - const apiKey = vscode.workspace.getConfiguration().get("codetations.apiKey") as string; + console.log(`Retagging annotation ${annotation.id}`); + + // Check for empty annotation text + if (annotation.start === annotation.end) { + console.warn(`Annotation ${annotation.id} has empty selection, using original positions`); + return { + ...annotation, + document: currentDocumentText, + start: annotation.original.start, + end: annotation.original.end + }; + } + + // Skip retagging if documents already match + if (annotation.document === currentDocumentText) { + console.log(`Annotation ${annotation.id} already matches current document, skipping retag`); + return annotation; + } // Use the retagUpdate function to get the new positions const result = await retagUpdate( @@ -442,13 +458,61 @@ export class SidebarProvider implements vscode.WebviewViewProvider { currentDocumentText, delimiter ); + + // Handle errors in the result + if (result.error) { + const errorMessage = result.error instanceof Error ? result.error.message : String(result.error); + console.error(`Retagging failed for annotation ${annotation.id}: ${errorMessage} (${result.errorType})`); + + // Display different messages based on error type + switch (result.errorType) { + case "model": + window.showErrorMessage(`Language model error while retagging: ${errorMessage}`); + break; + case "JSON parse": + case "JSON validation": + window.showErrorMessage(`Invalid response format while retagging: ${errorMessage}`); + break; + case "snippet matching": + window.showErrorMessage(`Could not locate annotation in updated code: ${errorMessage}`); + break; + case "validation": + window.showErrorMessage(`Validation error during retagging: ${errorMessage}`); + break; + default: + window.showErrorMessage(`Error retagging annotation: ${errorMessage}`); + } + + // For debugging + console.debug("Annotation details:", { + id: annotation.id, + start: annotation.start, + end: annotation.end, + text: annotation.document.substring(annotation.start, annotation.end), + errorType: result.errorType + }); + + // Return original annotation on error + return annotation; + } + + // Handle missing output if (!result.out) { + console.error(`Retagging returned no result for annotation ${annotation.id}`); window.showErrorMessage("Error retagging annotation: no result returned"); - console.error("Error retagging annotation: no result returned"); - console.error(codeWithSnippetDelimited); return annotation; } + // Validate the result + if (result.out.leftIdx < 0 || result.out.rightIdx > currentDocumentText.length || + result.out.leftIdx >= result.out.rightIdx) { + console.error(`Invalid retag positions: leftIdx=${result.out.leftIdx}, rightIdx=${result.out.rightIdx}`); + window.showErrorMessage("Error retagging annotation: invalid positions returned"); + return annotation; + } + + console.log(`Successfully retagged annotation ${annotation.id} to positions ${result.out.leftIdx}-${result.out.rightIdx}`); + // Update the annotation with the new positions return { ...annotation, @@ -457,8 +521,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { end: result.out.rightIdx, }; } catch (error) { - console.error("Error retagging annotation:", error); - window.showErrorMessage(`Error retagging annotation: ${error}`); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Unexpected error retagging annotation ${annotation.id}:`, error); + window.showErrorMessage(`Unexpected error retagging annotation: ${errorMessage}`); return annotation; // Return original annotation on error } } @@ -476,51 +541,122 @@ export class SidebarProvider implements vscode.WebviewViewProvider { const currentDocumentText = editor.document.getText(); const annotations = annotationTracker.getAnnotationsForDocument(editor.document); + // Check if there are annotations to retag + if (annotations.length === 0) { + window.showInformationMessage("No annotations found to retag"); + return; + } + + // Check if any annotations need retagging + const needsRetagging = annotations.some(ann => + ann.document !== currentDocumentText || ann.start === ann.end + ); + + if (!needsRetagging) { + window.showInformationMessage("All annotations are already up-to-date"); + return; + } + // Show progress notification window.withProgress( { location: vscode.ProgressLocation.Notification, title: "Retagging annotations...", - cancellable: false, + cancellable: true, }, - async (progress) => { + async (progress, token) => { try { // Update progress as we process each annotation const total = annotations.length; let processed = 0; + let successful = 0; + let skipped = 0; + let failed = 0; + + // Process annotations one by one instead of all at once + // This provides better progressive feedback and allows for early cancellation + const updatedAnnotations = []; + + for (const annotation of annotations) { + // Check for cancellation + if (token.isCancellationRequested) { + window.showInformationMessage("Annotation retagging was cancelled"); + break; + } - // Retag all annotations - const updatedAnnotations = await Promise.all( - annotations.map(async (annotation) => { - // HACK -- if the annotation is empty, use the original positions - if (annotation.start === annotation.end) { - annotation.document = annotation.original.document; - annotation.start = annotation.original.start; - annotation.end = annotation.original.end; + try { + // Process the annotation + let retagged; + + // Skip annotations that don't need retagging + if (annotation.document === currentDocumentText) { + retagged = annotation; + skipped++; + } else { + // Retag the annotation + retagged = await this._retagAnnotation(annotation, currentDocumentText); + + // Check if retagging was successful (positions changed) + if (retagged !== annotation) { + successful++; + } else { + // Annotation returned unchanged, which indicates a failure + failed++; + } } - if (annotation.document === currentDocumentText) { return annotation; } - const retagged = await this._retagAnnotation(annotation, currentDocumentText); + + updatedAnnotations.push(retagged); + + // Update progress processed++; progress.report({ - message: `Processed ${processed}/${total} annotations`, + message: `Processed ${processed}/${total} annotations (${successful} updated, ${failed} failed)`, increment: (1 / total) * 100, }); - return retagged; - }) - ); - // Update all annotations at once - annotations.forEach((oldAnnotation, index) => { - annotationTracker.updateAnnotation(editor.document, updatedAnnotations[index]); - }); + } catch (e) { + // Handle individual annotation failures + console.error(`Error processing annotation ${annotation.id}:`, e); + updatedAnnotations.push(annotation); // Keep original + failed++; + processed++; - // Notify webview of the updated annotations - this._loadAnnotationsForActiveEditor(); + progress.report({ + message: `Processed ${processed}/${total} annotations (${failed} failed)`, + increment: (1 / total) * 100, + }); + } + } + + // Only update annotations if we have processed at least some + if (processed > 0 && !token.isCancellationRequested) { + // Update all annotations at once + annotations.forEach((oldAnnotation, index) => { + if (index < updatedAnnotations.length) { + annotationTracker.updateAnnotation(editor.document, updatedAnnotations[index]); + } + }); + + // Notify webview of the updated annotations + this._loadAnnotationsForActiveEditor(); + + // Show summary + if (failed > 0) { + window.showWarningMessage( + `Retagging completed: ${successful} updated, ${skipped} already up-to-date, ${failed} failed` + ); + } else { + window.showInformationMessage( + `Retagging completed: ${successful} updated, ${skipped} already up-to-date` + ); + } + } return true; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); console.error("Error retagging annotations:", error); - window.showErrorMessage(`Error retagging annotations: ${error}`); + window.showErrorMessage(`Error retagging annotations: ${errorMessage}`); return false; } } diff --git a/vscode-extension/src/server/retag.ts b/vscode-extension/src/server/retag.ts index e198de2..0d6ef0b 100644 --- a/vscode-extension/src/server/retag.ts +++ b/vscode-extension/src/server/retag.ts @@ -3,22 +3,50 @@ import OpenAI from "openai"; // import dotenv from 'dotenv' function normalizeStringAndMapPositions(str: string) { - let normalized = ""; - let positionMap = []; - let originalPosition = 0; - - for (let i = 0; i < str.length; i++) { - if (str[i].match(/\s/) && (i === 0 || str[i - 1].match(/\s/))) { - // Skip multiple whitespaces - continue; - } - // Add character to normalized string and map its position - normalized += str[i].match(/\s/) ? " " : str[i]; - positionMap.push(originalPosition); - originalPosition = i + 1; + // Handle null or undefined input + if (!str) { + console.warn("Null or undefined string passed to normalizeStringAndMapPositions"); + return { normalized: "", positionMap: [0] }; } - return { normalized, positionMap }; + try { + let normalized = ""; + let positionMap = []; + + // Always start with position 0 + positionMap.push(0); + + // Normalize the string by collapsing whitespace and mapping positions + let previousWasWhitespace = false; + + for (let i = 0; i < str.length; i++) { + const isWhitespace = /\s/.test(str[i]); + + if (isWhitespace) { + // Only add a single space for consecutive whitespace characters + if (!previousWasWhitespace) { + normalized += " "; + positionMap.push(i); + previousWasWhitespace = true; + } + } else { + // Add non-whitespace character as is + normalized += str[i]; + positionMap.push(i); + previousWasWhitespace = false; + } + } + + // Ensure positionMap has at least one entry + if (positionMap.length === 0) { + positionMap.push(0); + } + + return { normalized, positionMap }; + } catch (e) { + console.error("Error in normalizeStringAndMapPositions:", e); + return { normalized: "", positionMap: [0] }; + } } function findOriginalPositions( @@ -27,34 +55,124 @@ function findOriginalPositions( matchEnd: number, positionMap: number[] ) { - // Adjust the positions based on the position map - const originalStart = positionMap[matchStart + 1] - 1; - const originalEnd = positionMap[matchEnd] + (originalStr[positionMap[matchEnd]] === " " ? 0 : 1); + try { + // Validate inputs + if (matchStart < 0 || matchEnd < matchStart || + matchStart >= positionMap.length || matchEnd >= positionMap.length) { + throw new Error(`Invalid match positions: start=${matchStart}, end=${matchEnd}, mapLength=${positionMap.length}`); + } + + // Carefully compute start position + let originalStart; + if (matchStart + 1 < positionMap.length) { + originalStart = positionMap[matchStart + 1] - 1; + } else { + // Fall back to direct match if we're at the end of the position map + originalStart = positionMap[matchStart]; + } + + // Validate originalStart + if (originalStart < 0 || originalStart >= originalStr.length) { + originalStart = Math.max(0, Math.min(originalStr.length - 1, positionMap[matchStart])); + } + + // Carefully compute end position + let originalEnd; + if (matchEnd < positionMap.length) { + originalEnd = positionMap[matchEnd]; + // Adjust end position based on whether it ends with whitespace + if (originalEnd < originalStr.length && originalStr[originalEnd] !== " ") { + originalEnd += 1; + } + } else { + // Fall back to string length if we're beyond the position map + originalEnd = originalStr.length; + } - return { originalStart, originalEnd }; + // Validate originalEnd + if (originalEnd <= originalStart || originalEnd > originalStr.length) { + originalEnd = Math.min(originalStr.length, originalStart + 1); + } + + return { originalStart, originalEnd }; + } catch (e) { + console.error("Error in findOriginalPositions:", e); + // Return safe defaults that won't crash the application + return { + originalStart: Math.max(0, Math.min(originalStr.length - 1, matchStart)), + originalEnd: Math.min(originalStr.length, Math.max(matchStart + 1, matchEnd)) + }; + } } function findStartAndEndNormalized(largerString: string, substring: string, nthOccurence = 0) { - // Normalize and map positions - const { normalized: normalizedLargerString, positionMap } = - normalizeStringAndMapPositions(largerString); - const { normalized: normalizedSubstring } = normalizeStringAndMapPositions(substring); - - // Assume we found the match in the normalized strings (example positions) - let matchStart = normalizedLargerString.indexOf(normalizedSubstring); - let matchEnd = matchStart + normalizedSubstring.length - 1; - - // Find original positions - const { originalStart, originalEnd } = findOriginalPositions( - largerString, - matchStart, - matchEnd, - positionMap - ); - return { - start: originalStart, - end: originalEnd, - }; + // Add validation for inputs + if (!largerString || !substring) { + console.error("Invalid inputs to findStartAndEndNormalized:", { largerString: !!largerString, substring: !!substring }); + return { start: -1, end: -1 }; + } + + try { + // Normalize and map positions + const { normalized: normalizedLargerString, positionMap } = + normalizeStringAndMapPositions(largerString); + const { normalized: normalizedSubstring } = normalizeStringAndMapPositions(substring); + + // If either normalization resulted in empty strings, return not found + if (!normalizedLargerString || !normalizedSubstring) { + console.warn("Normalization resulted in empty strings"); + return { start: -1, end: -1 }; + } + + // Find the nth occurrence of the substring + let matchStart = -1; + let currentIndex = 0; + let currentOccurrence = 0; + + // Handle case where nthOccurence is 0 (treat as first occurrence) + const targetOccurrence = nthOccurence === 0 ? 1 : nthOccurence; + + while (currentOccurrence < targetOccurrence) { + matchStart = normalizedLargerString.indexOf(normalizedSubstring, currentIndex); + + if (matchStart === -1) { + // Not enough occurrences found + console.warn(`Could not find occurrence ${targetOccurrence} of substring in text`); + return { start: -1, end: -1 }; + } + + currentOccurrence++; + if (currentOccurrence < targetOccurrence) { + currentIndex = matchStart + 1; + } + } + + const matchEnd = matchStart + normalizedSubstring.length - 1; + + // Make sure we have valid indices before trying to find original positions + if (matchStart >= 0 && matchEnd >= matchStart && + matchStart < positionMap.length && matchEnd < positionMap.length) { + + // Find original positions + const { originalStart, originalEnd } = findOriginalPositions( + largerString, + matchStart, + matchEnd, + positionMap + ); + + return { + start: originalStart, + end: originalEnd, + }; + } else { + console.warn("Invalid match indices:", { matchStart, matchEnd, positionMapLength: positionMap.length }); + return { start: -1, end: -1 }; + } + } catch (e) { + console.error("Error in findStartAndEndNormalized:", e); + return { start: -1, end: -1 }; + } } type CodeUpdate = { @@ -119,42 +237,75 @@ async function copilotPromptGPTForJSON(t: string) { vscode.LanguageModelChatMessage.User(t), vscode.LanguageModelChatMessage.User('Now respond with the JSON object only. ONLY output raw JSON. Do not emit markdown formatting.') ]; - // const craftedPrompt = [ - // vscode.LanguageModelChatMessage.User( - // 'You are a cat! Think carefully and step by step like a cat would. Your job is to explain computer science concepts in the funny manner of a cat, using cat metaphors. Always start your response by stating what concept you are explaining. Always include code samples.' - // ), - // vscode.LanguageModelChatMessage.User('I want to understand recursion') - // ]; + try { console.log('Selecting model...'); - const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); + // Try to select the model with a timeout + const modelSelection = await Promise.race([ + vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }), + new Promise((resolve) => setTimeout(() => resolve(null), 10000)) // 10 second timeout + ]); + + // Check if model selection timed out or returned null/empty array + if (!modelSelection || !Array.isArray(modelSelection) || modelSelection.length === 0) { + console.error('Model selection failed or timed out'); + throw new Error('Failed to select language model: Timeout or no models available'); + } + + const [model] = modelSelection; console.log('Selected model:', model); - const response = await model.sendRequest(craftedPrompt, {}, new vscode.CancellationTokenSource().token); - console.log('Got response:', response); - let fullResponse = ''; - for await (const chunk of response.text) { - console.debug('Got chunk:', chunk); - fullResponse += chunk; + + // Create a cancellation token with a timeout + const cts = new vscode.CancellationTokenSource(); + const timeoutMs = 30000; // 30 seconds + const timeout = setTimeout(() => cts.cancel(), timeoutMs); + + try { + const response = await model.sendRequest(craftedPrompt, {}, cts.token); + console.log('Got response:', response); + + let fullResponse = ''; + for await (const chunk of response.text) { + console.debug('Got chunk:', chunk); + fullResponse += chunk; + } + + // Clear the timeout since we got a response + clearTimeout(timeout); + + if (!fullResponse || fullResponse.trim() === '') { + throw new Error('Empty response from language model'); + } + + return fullResponse; + } finally { + clearTimeout(timeout); + cts.dispose(); } - return fullResponse; - // TODO figure out how to get the completion here } catch (err) { // Making the chat request might fail because // - model does not exist // - user consent not given // - quota limits were exceeded + // - timeout occurred + + let errorMessage = 'Language model request failed'; + if (err instanceof vscode.LanguageModelError) { console.error('Problem with extension LM api:', err.message, err.code, err.cause); - if (err.cause instanceof Error && err.cause.message.includes('off_topic')) { - // stream.markdown( - // vscode.l10n.t("I'm sorry, I can only explain computer science concepts.") - // ); - throw err; + errorMessage = `Language model error: ${err.message}`; + + if (err.cause instanceof Error) { + if (err.cause.message.includes('off_topic')) { + errorMessage = 'The model considers this request off-topic'; + } } - } else { - // add other error handling logic - throw err; + } else if (err instanceof Error) { + errorMessage = err.message; } + + console.error('Error in copilotPromptGPTForJSON:', errorMessage); + throw new Error(errorMessage); } } @@ -163,76 +314,110 @@ const retagUpdate = async ( updatedCodeWithoutDelimiters: string, delimiter: string ) => { - console.log(codeWithSnippetDelimited, updatedCodeWithoutDelimiters, delimiter); - // const gptOut = await getFirstChoice(await getChatCompletion( - // 'gpt-4-turbo-preview', - // [{role: 'user', - // content: prompt_breakdown9({codeWithSnippetDelimited, - // updatedCodeWithSnippetDelimited: updatedCodeWithoutDelimiters, - // delimiter})}])) - // console.log(gptOut) + // Validate inputs + if (!codeWithSnippetDelimited || !updatedCodeWithoutDelimiters || !delimiter) { + return { + error: new Error("Missing required parameters for retagging"), + errorType: "validation" + }; + } + + console.log("Retagging with delimiter:", delimiter); + console.log("Original code length:", codeWithSnippetDelimited.length); + console.log("Updated code length:", updatedCodeWithoutDelimiters.length); + + // Check if delimiter exists in the input code + if (!codeWithSnippetDelimited.includes(delimiter)) { + return { + error: new Error("Delimiter not found in input code"), + errorType: "validation" + }; + } let gptOut = ""; try { - // const gptOutCompletion = await openai.chat.completions.create({ - // messages: [ - // { - // role: "system", - // content: "You are a helpful assistant designed to output JSON.", - // }, - // { - // role: "user", - // content: prompt_breakdown11({ - // codeWithSnippetDelimited, - // updatedCodeWithSnippetDelimited: updatedCodeWithoutDelimiters, - // delimiter, - // }), - // }, - // ], - // model: "gpt-4o", - // response_format: { type: "json_object" }, - // }); - // console.log(gptOutCompletion); - - // gptOut = gptOutCompletion.choices[0]?.message.content || ""; - - gptOut = await copilotPromptGPTForJSON( prompt_breakdown11({ + // Create the prompt for the language model + const promptText = prompt_breakdown11({ codeWithSnippetDelimited, updatedCodeWithSnippetDelimited: updatedCodeWithoutDelimiters, delimiter, - }) ) || ''; - console.log(gptOut); + }); + + // Get response from language model + gptOut = await copilotPromptGPTForJSON(promptText) || ''; + console.log("LM Response:", gptOut.substring(0, 200) + (gptOut.length > 200 ? "..." : "")); + + // Check if response is empty + if (!gptOut || gptOut.trim() === '') { + return { + error: new Error("Empty response from language model"), + errorType: "model", + prompt: promptText + }; + } } catch (e) { - return { error: e, errorType: "model" }; + console.error("Error getting response from language model:", e); + return { + error: e instanceof Error ? e : new Error(String(e)), + errorType: "model" + }; } + + // Parse the JSON response let gptRetaggingJSON; try { gptRetaggingJSON = JSON.parse(gptOut); - console.log(gptRetaggingJSON); + console.log("Parsed JSON:", gptRetaggingJSON); + + // Validate the expected JSON structure + if (!gptRetaggingJSON || + typeof gptRetaggingJSON !== 'object' || + !gptRetaggingJSON[1] || + !gptRetaggingJSON[2] || + !gptRetaggingJSON[3] || + !gptRetaggingJSON[4]) { + return { + error: new Error("Invalid JSON structure in model response"), + errorType: "JSON validation", + gptOut + }; + } + + // Validate the types of JSON values + if (typeof gptRetaggingJSON[1] !== 'string' || + typeof gptRetaggingJSON[2] !== 'number' || + typeof gptRetaggingJSON[3] !== 'number' || + typeof gptRetaggingJSON[4] !== 'number') { + return { + error: new Error("Invalid data types in model response"), + errorType: "JSON validation", + gptOut + }; + } + + // Validate the ranges of line numbers + const lineCount = updatedCodeWithoutDelimiters.split('\n').length; + if (gptRetaggingJSON[2] < 1 || + gptRetaggingJSON[3] < gptRetaggingJSON[2] || + gptRetaggingJSON[2] > lineCount || + gptRetaggingJSON[3] > lineCount) { + return { + error: new Error(`Invalid line numbers: [${gptRetaggingJSON[2]}, ${gptRetaggingJSON[3]}], document has ${lineCount} lines`), + errorType: "range validation", + gptOut + }; + } + } catch (e) { - // (This should never happen based on the OpenAI documentation.) - return { error: e, errorType: "JSON parse", gptOut }; + console.error("Error parsing JSON from model response:", e); + return { + error: e instanceof Error ? e : new Error(String(e)), + errorType: "JSON parse", + gptOut + }; } - // console.log(completion.choices[0].message.content); - /* Unhandled issues: - * the response may be incorrect; could check across several tries to mitigate - * the response may be correct but there may be multiple correct responses; disambiguation needed - * the response may have an unreadable format, leading to failure in the next part - */ - - // const gptRetaggingJSONString = (await askMX([ - // { role: 'system', - // content: 'You are a text parser designed to output JSON.'}, - // { role: 'user', - // content: retagPromptGPT(gptOut)} - // ])).trim() // sometimes Mixtral puts a space in front of the response... - // console.log(gptRetaggingJSONString) - // const gptRetaggingJSON = JSON.parse(gptRetaggingJSONString) - /* Unhandled issues: - * the response may be incorrect given the input - * failure to parse - */ + // Helper type for the parameter structure type UpdateParameters = { code: string; snippet: string; @@ -243,6 +428,7 @@ const retagUpdate = async ( delimiterEnd: string; }; + // Function to compute the new position of the annotation const computeUpdatedCodeWithSnippetRetagged = ({ code, snippet, @@ -252,45 +438,67 @@ const retagUpdate = async ( delimiterStart, delimiterEnd, }: UpdateParameters) => { - // Note lineStart and lineEnd are 1-indexed. - /* We expand the search by one line if it fails on the identified segment to handle off-by-one issues. */ - /* NOTE expanded search was introduced after the initial evaluation. - /* Unhandled issues: - * any non-whitespace typos in the output (even e.g. missing comments) will cause a failure to match - * potentially allowing the model to place the delimiter interactively - would guarantee placement in the "intended" location, - but this is slow - */ + // Validate function inputs + if (!code || !snippet || lineStart < 1 || lineEnd < lineStart) { + throw new Error(`Invalid parameters: code=${!!code}, snippet=${!!snippet}, lineStart=${lineStart}, lineEnd=${lineEnd}`); + } + + console.log(`Computing annotation position: lines ${lineStart}-${lineEnd}, occurrence #${nthOccurrence}`); + console.log(`Snippet (first 50 chars): ${snippet.substring(0, 50)}${snippet.length > 50 ? "..." : ""}`); + // Note lineStart and lineEnd are 1-indexed. + // Get the section of code where we expect to find the snippet let sectionString = code .split("\n") .slice(lineStart - 1, lineEnd) .join("\n"); + let lenUpToSection = code .split("\n") .slice(0, lineStart - 1) .map((s) => s + "\n") .join("").length; + + // Try to find the snippet in the section let snippetIdxInSection = findStartAndEndNormalized(sectionString, snippet, nthOccurrence); + + // If not found, expand the search area by one line in each direction if (snippetIdxInSection.start === -1) { - lineStart = Math.max(0, lineStart - 1); + console.log("Snippet not found in initial section, expanding search area"); + lineStart = Math.max(1, lineStart - 1); lineEnd = Math.min(lineEnd + 1, code.split("\n").length); + sectionString = code .split("\n") .slice(lineStart - 1, lineEnd) .join("\n"); + lenUpToSection = code .split("\n") .slice(0, lineStart - 1) .map((s) => s + "\n") .join("").length; + snippetIdxInSection = findStartAndEndNormalized(sectionString, snippet, nthOccurrence); + + // If still not found, throw an error + if (snippetIdxInSection.start === -1) { + throw new Error(`Snippet not found in code section (lines ${lineStart}-${lineEnd})`); + } } - // const sectionString = code.split('\n').slice(lineStart - 1, lineEnd).join('\n') - // const lenUpToSection = code.split('\n').slice(0, lineStart - 1).map(s=>s + '\n').join('').length - // const snippetIdxInSection = findStartAndEndNormalized(sectionString, snippet, nthOccurrence) + + // Compute the absolute positions in the file const leftIdx = lenUpToSection + snippetIdxInSection.start; const rightIdx = leftIdx + snippetIdxInSection.end - snippetIdxInSection.start; + + // Validate the calculated indices + if (leftIdx < 0 || rightIdx > code.length || leftIdx >= rightIdx) { + throw new Error(`Invalid annotation indices: leftIdx=${leftIdx}, rightIdx=${rightIdx}, codeLength=${code.length}`); + } + + console.log(`Found snippet at positions ${leftIdx}-${rightIdx}`); + + // Return the updated code with delimiters and the positions return { updatedCodeWithDelimiters: code.slice(0, leftIdx) + @@ -302,7 +510,10 @@ const retagUpdate = async ( rightIdx, }; }; + + // Try to compute the new annotation position try { + console.log("Attempting to compute updated annotation position"); const out = computeUpdatedCodeWithSnippetRetagged({ code: updatedCodeWithoutDelimiters, snippet: gptRetaggingJSON[1], @@ -313,12 +524,12 @@ const retagUpdate = async ( delimiterEnd: delimiter, }); - console.log(out); - + console.log("Successfully retagged annotation at:", out.leftIdx, out.rightIdx); return { gptRetaggingJSON, out }; } catch (e) { + console.error("Error computing annotation position:", e); return { - error: e, + error: e instanceof Error ? e : new Error(String(e)), errorType: "snippet matching", gptOut, gptRetaggingJSON, From e230a4b31fe344095c7aeb00bae37149f54426fa Mon Sep 17 00:00:00 2001 From: Erik Vank Date: Mon, 12 May 2025 09:41:52 -0700 Subject: [PATCH 2/4] fix: retag errors --- vscode-extension/src/panels/AnnotationManagerPanel.ts | 4 ++-- vscode-extension/src/panels/BaseAnnotationView.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vscode-extension/src/panels/AnnotationManagerPanel.ts b/vscode-extension/src/panels/AnnotationManagerPanel.ts index 5518fa1..0309b43 100644 --- a/vscode-extension/src/panels/AnnotationManagerPanel.ts +++ b/vscode-extension/src/panels/AnnotationManagerPanel.ts @@ -499,7 +499,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // Handle missing output if (!result.out) { console.error(`Retagging returned no result for annotation ${annotation.id}`); - window.showErrorMessage("Error retagging annotation: no result returned"); + window.showErrorMessage("Error retagging annotation: no result returned (maybe all annotation anchor text was deleted?)"); return annotation; } @@ -575,7 +575,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // Process annotations one by one instead of all at once // This provides better progressive feedback and allows for early cancellation - const updatedAnnotations = []; + const updatedAnnotations: Annotation[] = []; for (const annotation of annotations) { // Check for cancellation diff --git a/vscode-extension/src/panels/BaseAnnotationView.ts b/vscode-extension/src/panels/BaseAnnotationView.ts index 950f894..c6a2fe3 100644 --- a/vscode-extension/src/panels/BaseAnnotationView.ts +++ b/vscode-extension/src/panels/BaseAnnotationView.ts @@ -315,7 +315,7 @@ export abstract class BaseAnnotationView { delimiter ); if (!result.out) { - window.showErrorMessage("Error retagging annotation: no result returned"); + window.showErrorMessage("Error retagging annotation: no result returned (maybe all annotation anchor text was deleted?)"); console.error("Error retagging annotation: no result returned"); console.error(codeWithSnippetDelimited); return annotation; From 9248b5d8baf3fc9bf47285673388621d9470a87d Mon Sep 17 00:00:00 2001 From: Erik Vank Date: Mon, 12 May 2025 09:45:45 -0700 Subject: [PATCH 3/4] feat: dark mode --- .../src/panels/BaseAnnotationView.ts | 27 +++- vscode-extension/webview-ui/src/App.css | 129 +++++++++++++--- vscode-extension/webview-ui/src/App.tsx | 138 ++++++++++++++---- .../webview-ui/src/DarkModeToggle.tsx | 37 +++++ 4 files changed, 279 insertions(+), 52 deletions(-) create mode 100644 vscode-extension/webview-ui/src/DarkModeToggle.tsx diff --git a/vscode-extension/src/panels/BaseAnnotationView.ts b/vscode-extension/src/panels/BaseAnnotationView.ts index c6a2fe3..72ff3a3 100644 --- a/vscode-extension/src/panels/BaseAnnotationView.ts +++ b/vscode-extension/src/panels/BaseAnnotationView.ts @@ -60,13 +60,17 @@ export abstract class BaseAnnotationView { // Get annotations from the tracker const annotations = annotationTracker.getAnnotationsForDocument(editor.document); + // Get dark mode preference + const isDarkMode = vscode.workspace.getConfiguration().get('codetations.darkMode', false); + // Send information to webview this.sendMessageObject({ command: "initialize", data: { documentUri: editor.document.uri.toString(), documentText: editor.document.getText(), - annotations: annotations + annotations: annotations, + isDarkMode: isDarkMode }, }); } @@ -155,7 +159,8 @@ export abstract class BaseAnnotationView { if (!editor) { // Most commands require an active editor - if (command !== "hello" && command !== "lm.chat" && command !== "lm.cancelRequest") { + if (command !== "hello" && command !== "lm.chat" && command !== "lm.cancelRequest" && + command !== "setDarkMode" && command !== "open-external") { window.showErrorMessage("No active text editor found"); return; } @@ -201,7 +206,7 @@ export abstract class BaseAnnotationView { // Retag annotations in the active document this.retagAnnotations(); return; - + case "setSelectedAnnotationId": // Set the selected annotation ID if (!editor) { @@ -266,9 +271,23 @@ export abstract class BaseAnnotationView { } return; + case "setDarkMode": + // Store dark mode preference in extension context + try { + // Store the dark mode preference in the extension's global state + vscode.workspace.getConfiguration().update( + 'codetations.darkMode', + message.data.isDarkMode, + vscode.ConfigurationTarget.Global + ); + } catch (error) { + console.error("Error saving dark mode preference to extension:", error); + } + return; + case "open-external": // Open link in external window - vscode.env.openExternal(message.url); + vscode.env.openExternal(vscode.Uri.parse(message.url)); return; } }, diff --git a/vscode-extension/webview-ui/src/App.css b/vscode-extension/webview-ui/src/App.css index 67d90e7..87d385c 100644 --- a/vscode-extension/webview-ui/src/App.css +++ b/vscode-extension/webview-ui/src/App.css @@ -1,5 +1,49 @@ @import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); +:root { + /* Light mode variables */ + --background-color: #ffffff; + --text-color: #333333; + --tile-bg-color: #f5f5f5; + --tile-border-color: #dddddd; + --tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --tile-selected-bg: #e0e0e0; + --banner-bg-color: #f8f9fa; + --banner-border-color: #dee2e6; + --button-color: #0d6efd; + --button-text-color: white; + --cancel-button-bg: #f8f9fa; + --cancel-button-border: #ced4da; + --error-banner-bg: #ffe0e0; + --input-border-color: #ced4da; + --input-bg-color: white; +} + +body.dark-mode { + /* Dark mode variables */ + --background-color: #1e1e1e; + --text-color: #e0e0e0; + --tile-bg-color: #2d2d2d; + --tile-border-color: #444444; + --tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + --tile-selected-bg: #3d3d3d; + --banner-bg-color: #252526; + --banner-border-color: #444444; + --button-color: #0e63c4; + --button-text-color: white; + --cancel-button-bg: #3a3a3a; + --cancel-button-border: #555555; + --error-banner-bg: #5a3232; + --input-border-color: #555555; + --input-bg-color: #2d2d2d; +} + +body { + background-color: var(--background-color); + color: var(--text-color); + transition: background-color 0.3s ease, color 0.3s ease; +} + main { display: flex; flex-direction: column; @@ -8,6 +52,7 @@ main { height: 100%; padding: 10px; position: relative; + color: var(--text-color); } .center-vertical { @@ -15,16 +60,36 @@ main { align-items: center; } +.dark-mode-toggle { + position: fixed; + top: 10px; + right: 10px; + background-color: transparent; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 6px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.dark-mode-toggle:hover { + background-color: var(--tile-selected-bg); +} + .annotation-tile { - background-color: #f5f5f5; /* Light grey background */ - border: 1px solid #ddd; /* Light border */ - padding: 5px 7px; /* Padding inside the tile */ - margin-bottom: 10px; /* Space between tiles */ - border-radius: 5px; /* Rounded corners */ - transition: background-color 0.3s; /* Smooth transition for background color */ - /* drop shadow */ - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background-color: var(--tile-bg-color); + border: 1px solid var(--tile-border-color); + padding: 5px 7px; + margin-bottom: 10px; + border-radius: 5px; + transition: background-color 0.3s, border-color 0.3s, box-shadow 0.3s; + box-shadow: var(--tile-shadow); width: -webkit-fill-available; + color: var(--text-color); } .annotation-tile * { @@ -32,11 +97,41 @@ main { } .annotation-tile.selected { - background-color: #e0e0e0; /* Slightly darker background when selected */ + background-color: var(--tile-selected-bg); } .add-note-banner, .confirm-annotation-dialog, .choose-annotation-type { backdrop-filter: blur(2px); + background-color: var(--banner-bg-color); + border-color: var(--banner-border-color); + color: var(--text-color); +} + +.add-note-banner select, .choose-annotation-type select, +.add-note-banner input, .choose-annotation-type input { + background-color: var(--input-bg-color); + color: var(--text-color); + border-color: var(--input-border-color); +} + +.retag-banner { + background-color: var(--error-banner-bg) !important; + color: var(--text-color); +} + +/* Button styling */ +button { + background-color: var(--button-color); + color: var(--button-text-color); + border: none; + border-radius: 4px; +} + +button.secondary, +.choose-annotation-type button[style*="background-color: #f8f9fa"] { + background-color: var(--cancel-button-bg) !important; + border: 1px solid var(--cancel-button-border) !important; + color: var(--text-color) !important; } /* This ensures the annotations remain visible under the banner */ @@ -44,14 +139,8 @@ main { z-index: 1; } -/* textarea { - resize: vertical; - width: 280px; - height: 80px; - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px; - font-family: "Poppins", sans-serif; - font-size: 14px; - color: black; -} */ +textarea, select, input { + background-color: var(--input-bg-color); + color: var(--text-color); + border-color: var(--input-border-color); +} diff --git a/vscode-extension/webview-ui/src/App.tsx b/vscode-extension/webview-ui/src/App.tsx index 05e9390..9ad0c6a 100644 --- a/vscode-extension/webview-ui/src/App.tsx +++ b/vscode-extension/webview-ui/src/App.tsx @@ -3,6 +3,7 @@ import "./App.css"; import Annotation from "./Annotation"; import { tools, toolNames } from "./tools"; import React, { useState, useEffect, useRef } from "react"; +import DarkModeToggle from "./DarkModeToggle"; interface AnnotationUpdate { document?: string; @@ -358,13 +359,14 @@ function App() { const [hoveredAnnotationId, setHoveredAnnotationId] = useState(undefined); const [chooseAnnotationType, setChooseAnnotationType] = useState(false); const [confirmAnnotation, setConfirmAnnotation] = useState(false); - + const [isDarkMode, setIsDarkMode] = useState(false); + // Annotation creation state const [start, setStart] = useState(undefined); const [end, setEnd] = useState(undefined); const defaultTool = Object.keys(toolTypes).length > 0 ? Object.keys(toolTypes)[0] : undefined; const [newTool, setNewTool] = useState(defaultTool); - + // Track current selection in editor const [currentSelection, setCurrentSelection] = useState<{start: number, end: number} | null>(null); const hasSelection = currentSelection && currentSelection.start !== currentSelection.end; @@ -519,20 +521,103 @@ function App() { setEnd(undefined); }; + // Toggle dark mode + const toggleDarkMode = () => { + const newDarkModeState = !isDarkMode; + setIsDarkMode(newDarkModeState); + + // Update body class for CSS + if (newDarkModeState) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + + // Save preference to localStorage + try { + localStorage.setItem('codetations-dark-mode', newDarkModeState ? 'true' : 'false'); + } catch (e) { + console.error("Error saving dark mode preference:", e); + } + + // Send dark mode change to extension + vscode.postMessage({ + command: "setDarkMode", + data: { + isDarkMode: newDarkModeState + } + }); + }; + + // Load dark mode preference on startup + useEffect(() => { + try { + // Try to get from localStorage + const savedDarkMode = localStorage.getItem('codetations-dark-mode'); + const prefersDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + // First check localStorage, then check system preference + const shouldUseDarkMode = savedDarkMode + ? savedDarkMode === 'true' + : prefersDarkMode; + + if (shouldUseDarkMode) { + setIsDarkMode(true); + document.body.classList.add('dark-mode'); + } + } catch (e) { + console.error("Error loading dark mode preference:", e); + } + + // Listen for system color scheme changes + const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleColorSchemeChange = (e: MediaQueryListEvent) => { + // Only change if user hasn't set a preference + if (!localStorage.getItem('codetations-dark-mode')) { + setIsDarkMode(e.matches); + if (e.matches) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + } + }; + + try { + // Add listener for system preference changes + darkModeMediaQuery.addEventListener('change', handleColorSchemeChange); + // Remove listener on cleanup + return () => darkModeMediaQuery.removeEventListener('change', handleColorSchemeChange); + } catch (e) { + // Fallback for older browsers that don't support addEventListener on MediaQueryList + console.warn("Browser doesn't support MediaQueryList.addEventListener", e); + } + }, []); + useEffect(() => { // Message handler for communication with extension const handleMessage = (event: MessageEvent) => { const message = JSON.parse(event.data); console.debug("Received message:", message); - + switch (message.command) { case "initialize": // Initialize the UI with document data and annotations setDocumentUri(message.data.documentUri); setDocumentText(message.data.documentText); setAnnotations(message.data.annotations || []); + + // Check if dark mode preference is present in initialization data + if (message.data.isDarkMode !== undefined) { + setIsDarkMode(message.data.isDarkMode); + if (message.data.isDarkMode) { + document.body.classList.add('dark-mode'); + } else { + document.body.classList.remove('dark-mode'); + } + } return; - + case "updateAnnotations": // Update annotations from extension if (message.data.documentUri === documentUri) { @@ -540,7 +625,7 @@ function App() { setDocumentText(message.data.documentText); } return; - + case "handleCursorPositionChange": // Handle cursor position change setCharNum(message.data.position); @@ -559,29 +644,29 @@ function App() { } } return; - + case "addAnnotation": // Start annotation creation flow handleAddAnnotation(message.data.start, message.data.end); return; - + case "removeAnnotation": // Remove selected annotation handleRemoveAnnotation(); return; - + case "setAnnotationColor": // Set color for selected annotation handleSetAnnotationColor(message.data.color); return; - + case "chooseAnnotationType": // Choose annotation type handleChooseAnnType(message.data.start, message.data.end, message.data.documentContent); return; } }; - + window.addEventListener("message", handleMessage); return () => { window.removeEventListener("message", handleMessage); @@ -610,6 +695,8 @@ function App() { if (!chooseAnnotationType) { return (
+ + {hasSelection && ( )} - + {showRetagBanner && ( - )} - + - {!hasSelection && ( -

To add more annotations, highlight text in the editor and choose a note type.

+ {!hasSelection && annotations.length === 0 && ( +

To add annotations, highlight text in the editor and choose a note type.

)}
); } else { return (
+ +
@@ -664,10 +751,9 @@ function App() { onChange={(e) => { setNewTool(e.target.value); }} - style={{ + style={{ padding: '6px 10px', borderRadius: '4px', - border: '1px solid #ced4da', flexGrow: 1 }} > @@ -677,25 +763,21 @@ function App() { ))} - -
- + void; +} + +const DarkModeToggle: React.FC = ({ isDarkMode, toggleDarkMode }) => { + return ( + + ); +}; + +export default DarkModeToggle; \ No newline at end of file From 64166fc8e1c42061bc8e89fe28bc468ea26f4123 Mon Sep 17 00:00:00 2001 From: Erik Vank Date: Mon, 12 May 2025 09:58:43 -0700 Subject: [PATCH 4/4] feat: ui polish --- .../webview-ui/src/AddAnnotationBanner.tsx | 77 +++ .../webview-ui/src/AnnotationTile.tsx | 74 +++ vscode-extension/webview-ui/src/App.css | 499 ++++++++++++++++-- vscode-extension/webview-ui/src/App.tsx | 401 ++++++-------- .../webview-ui/src/DarkModeToggle.tsx | 4 +- .../webview-ui/src/EmptyState.tsx | 56 ++ vscode-extension/webview-ui/src/Header.tsx | 70 +++ 7 files changed, 901 insertions(+), 280 deletions(-) create mode 100644 vscode-extension/webview-ui/src/AddAnnotationBanner.tsx create mode 100644 vscode-extension/webview-ui/src/AnnotationTile.tsx create mode 100644 vscode-extension/webview-ui/src/EmptyState.tsx create mode 100644 vscode-extension/webview-ui/src/Header.tsx diff --git a/vscode-extension/webview-ui/src/AddAnnotationBanner.tsx b/vscode-extension/webview-ui/src/AddAnnotationBanner.tsx new file mode 100644 index 0000000..1600602 --- /dev/null +++ b/vscode-extension/webview-ui/src/AddAnnotationBanner.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { toolNames } from './tools'; + +interface AddAnnotationBannerProps { + selectedTool: string | undefined; + setSelectedTool: (tool: string) => void; + onConfirm: () => void; + onCancel: () => void; + toolTypes: { + [key: string]: React.FC; + }; +} + +const AddAnnotationBanner: React.FC = ({ + selectedTool, + setSelectedTool, + onConfirm, + onCancel, + toolTypes +}) => { + return ( +
+
+
+ + + + + Add Annotation +
+ +
+ + +
+
+ +
+ + +
+
+ ); +}; + +export default AddAnnotationBanner; \ No newline at end of file diff --git a/vscode-extension/webview-ui/src/AnnotationTile.tsx b/vscode-extension/webview-ui/src/AnnotationTile.tsx new file mode 100644 index 0000000..f43aa7a --- /dev/null +++ b/vscode-extension/webview-ui/src/AnnotationTile.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { toolNames } from './tools'; + +interface AnnotationTileProps { + annotation: any; + selected: boolean; + isOutOfSync: boolean; + documentText: string; + onClick: () => void; + onDelete: (id: string, event: React.MouseEvent) => void; + children: React.ReactNode; +} + +const AnnotationTile: React.FC = ({ + annotation, + selected, + isOutOfSync, + documentText, + onClick, + onDelete, + children +}) => { + const lineNumber = documentText + .slice(0, annotation.start) + .split('\n') + .length; + + const colorStyle = { + borderLeft: annotation.metadata?.color + ? `5px solid ${annotation.metadata.color}` + : '5px solid rgba(255,255,0,0.3)', + }; + + return ( +
+
+
+ Line {lineNumber} +
+ {annotation.tool && ( +
+ {toolNames[annotation.tool as keyof typeof toolNames]} +
+ )} + {isOutOfSync && ( +
+ Needs Update +
+ )} + +
+
+ {children} +
+
+ ); +}; + +export default AnnotationTile; \ No newline at end of file diff --git a/vscode-extension/webview-ui/src/App.css b/vscode-extension/webview-ui/src/App.css index 87d385c..c505d5e 100644 --- a/vscode-extension/webview-ui/src/App.css +++ b/vscode-extension/webview-ui/src/App.css @@ -1,146 +1,551 @@ -@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"); :root { /* Light mode variables */ --background-color: #ffffff; --text-color: #333333; - --tile-bg-color: #f5f5f5; - --tile-border-color: #dddddd; - --tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - --tile-selected-bg: #e0e0e0; - --banner-bg-color: #f8f9fa; - --banner-border-color: #dee2e6; - --button-color: #0d6efd; + --text-secondary: #6c757d; + --tile-bg-color: #f8f9fa; + --tile-border-color: rgba(0, 0, 0, 0.07); + --tile-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + --tile-selected-bg: #f0f7ff; + --tile-selected-border: rgba(13, 110, 253, 0.2); + --tile-hover-bg: #f2f2f2; + --banner-bg-color: #ffffff; + --banner-border-color: rgba(0, 0, 0, 0.08); + --primary-color: #0d6efd; + --primary-hover: #0b5ed7; --button-text-color: white; --cancel-button-bg: #f8f9fa; --cancel-button-border: #ced4da; - --error-banner-bg: #ffe0e0; + --cancel-button-hover: #e9ecef; + --error-banner-bg: #fff8f8; + --error-banner-border: #ffcccc; + --error-text: #dc3545; --input-border-color: #ced4da; --input-bg-color: white; + --input-focus-border: #86b7fe; + --input-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + --empty-state-bg: #f8f9fa; + --header-bg: #ffffff; + --header-border: rgba(0, 0, 0, 0.08); + --success-color: #20c997; + --warning-color: #fd7e14; + --info-color: #0dcaf0; } body.dark-mode { /* Dark mode variables */ --background-color: #1e1e1e; --text-color: #e0e0e0; - --tile-bg-color: #2d2d2d; - --tile-border-color: #444444; - --tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - --tile-selected-bg: #3d3d3d; + --text-secondary: #9e9e9e; + --tile-bg-color: #252526; + --tile-border-color: rgba(255, 255, 255, 0.07); + --tile-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + --tile-selected-bg: #133056; + --tile-selected-border: rgba(77, 156, 255, 0.3); + --tile-hover-bg: #303030; --banner-bg-color: #252526; - --banner-border-color: #444444; - --button-color: #0e63c4; + --banner-border-color: rgba(255, 255, 255, 0.08); + --primary-color: #3b8efc; + --primary-hover: #5fa0ff; --button-text-color: white; --cancel-button-bg: #3a3a3a; --cancel-button-border: #555555; - --error-banner-bg: #5a3232; + --cancel-button-hover: #444444; + --error-banner-bg: #3d2626; + --error-banner-border: #5a3232; + --error-text: #f87171; --input-border-color: #555555; --input-bg-color: #2d2d2d; + --input-focus-border: #3b8efc; + --input-focus-shadow: 0 0 0 0.25rem rgba(59, 142, 252, 0.25); + --empty-state-bg: #252526; + --header-bg: #252526; + --header-border: rgba(255, 255, 255, 0.08); + --success-color: #20b985; + --warning-color: #e67e22; + --info-color: #3498db; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; } body { background-color: var(--background-color); color: var(--text-color); transition: background-color 0.3s ease, color 0.3s ease; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } main { display: flex; flex-direction: column; - justify-content: center; - align-items: flex-start; height: 100%; - padding: 10px; position: relative; color: var(--text-color); + min-height: 100vh; + width: 100%; + overflow-x: hidden; +} + +.app-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: 0; + position: relative; +} + +.content-area { + flex: 1; + padding: 16px; + overflow-y: auto; + margin-top: 68px; /* Space for fixed header - match header height */ + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +.header { + background-color: var(--header-bg); + border-bottom: 1px solid var(--header-border); + padding: 0 20px; + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 68px; /* Increased to accommodate larger toggle */ + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + backdrop-filter: blur(8px); +} + +.header-title { + display: flex; + align-items: center; } -.center-vertical { +.header h1 { + margin: 0; + font-size: 16px; + font-weight: 600; display: flex; align-items: center; } +.header svg { + transition: transform 0.3s ease; +} + +.header:hover svg { + transform: rotate(-5deg); +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.retag-button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + background-color: var(--error-banner-bg); + color: var(--error-text); + border: 1px solid var(--error-banner-border); + font-weight: 500; +} + +.retag-button:hover { + background-color: var(--error-banner-bg); + filter: brightness(0.98); +} + +/* Dark mode toggle */ + .dark-mode-toggle { - position: fixed; - top: 10px; - right: 10px; - background-color: transparent; - border: none; + background-color: var(--tile-hover-bg); + border: 1px solid var(--tile-border-color); color: var(--text-color); cursor: pointer; - padding: 6px; + width: 44px; + height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - z-index: 1000; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + margin-left: 8px; } .dark-mode-toggle:hover { - background-color: var(--tile-selected-bg); + background-color: var(--tile-hover-bg); + transform: scale(1.05); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +.dark-mode-toggle:active { + transform: scale(0.95); +} + +.dark-mode-toggle svg { + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + color: var(--text-color); + opacity: 0.9; +} + +.dark-mode-toggle:hover svg { + transform: rotate(12deg); + opacity: 1; +} + +/* Annotation tiles */ +.annotations-list { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding-bottom: 16px; } .annotation-tile { background-color: var(--tile-bg-color); border: 1px solid var(--tile-border-color); - padding: 5px 7px; - margin-bottom: 10px; - border-radius: 5px; - transition: background-color 0.3s, border-color 0.3s, box-shadow 0.3s; + padding: 12px; + border-radius: 8px; + transition: all 0.2s ease; box-shadow: var(--tile-shadow); - width: -webkit-fill-available; + width: 100%; color: var(--text-color); + position: relative; + overflow: hidden; + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.annotation-tile:hover { + background-color: var(--tile-hover-bg); + transform: translateY(-2px); + box-shadow: var(--tile-shadow), 0 4px 12px rgba(0, 0, 0, 0.05); } .annotation-tile * { - max-width: -webkit-fill-available !important; + max-width: 100% !important; } .annotation-tile.selected { background-color: var(--tile-selected-bg); + border-color: var(--tile-selected-border); + box-shadow: 0 0 0 1px var(--tile-selected-border), var(--tile-shadow); +} + +.annotation-info { + display: flex; + gap: 8px; + font-size: 12px; + align-items: center; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.line-number { + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; +} + +body.dark-mode .line-number { + background-color: rgba(255, 255, 255, 0.05); +} + +.annotation-type { + font-weight: 600; +} + +.needs-retag-indicator { + margin-left: auto; + background-color: var(--error-banner-bg); + color: var(--error-text); + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.delete-button { + background-color: transparent !important; + padding: 6px !important; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--text-secondary) !important; + opacity: 0.6; + transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease; +} + +.delete-button:hover { + background-color: rgba(220, 53, 69, 0.1) !important; + color: var(--error-text) !important; + opacity: 1; +} + +.annotation-container { + z-index: 1; + width: 100%; +} + +/* Banner styling */ +.banner { + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + animation: slideDown 0.3s ease; + width: 100%; +} + +.banner-content { + width: 100%; +} + +.banner-content h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 600; +} + +.banner-content p { + margin: 0 0 16px 0; + color: var(--text-secondary); +} + +.form-control { + margin-bottom: 16px; +} + +.button-group { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.retag-banner { + background-color: var(--error-banner-bg); + border: 1px solid var(--error-banner-border); } .add-note-banner, .confirm-annotation-dialog, .choose-annotation-type { - backdrop-filter: blur(2px); + backdrop-filter: blur(10px); background-color: var(--banner-bg-color); - border-color: var(--banner-border-color); + border: 1px solid var(--banner-border-color); color: var(--text-color); + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + padding: 16px; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); + animation: slideUp 0.3s ease; +} + +.choose-annotation-type { + top: 0; + bottom: auto; + animation: slideDown 0.3s ease; + border-top: none; + border-left: none; + border-right: none; + border-radius: 0; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } } .add-note-banner select, .choose-annotation-type select, .add-note-banner input, .choose-annotation-type input { background-color: var(--input-bg-color); color: var(--text-color); - border-color: var(--input-border-color); + border: 1px solid var(--input-border-color); + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } -.retag-banner { - background-color: var(--error-banner-bg) !important; - color: var(--text-color); +.add-note-banner select:focus, .choose-annotation-type select:focus, +.add-note-banner input:focus, .choose-annotation-type input:focus { + outline: none; + border-color: var(--input-focus-border); + box-shadow: var(--input-focus-shadow); } /* Button styling */ button { - background-color: var(--button-color); + background-color: var(--primary-color); color: var(--button-text-color); border: none; - border-radius: 4px; + border-radius: 6px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease; +} + +button:hover { + background-color: var(--primary-hover); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +button:active { + transform: translateY(1px); + box-shadow: none; } button.secondary, -.choose-annotation-type button[style*="background-color: #f8f9fa"] { +.choose-annotation-type button.secondary { background-color: var(--cancel-button-bg) !important; border: 1px solid var(--cancel-button-border) !important; color: var(--text-color) !important; } -/* This ensures the annotations remain visible under the banner */ -.annotation-container { - z-index: 1; +button.secondary:hover { + background-color: var(--cancel-button-hover) !important; } +/* Form controls */ textarea, select, input { background-color: var(--input-bg-color); color: var(--text-color); - border-color: var(--input-border-color); + border: 1px solid var(--input-border-color); + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + width: 100%; +} + +textarea:focus, select:focus, input:focus { + outline: none; + border-color: var(--input-focus-border); + box-shadow: var(--input-focus-shadow); +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px; + background-color: var(--empty-state-bg); + border-radius: 12px; + margin: 40px auto; + max-width: 500px; + color: var(--text-secondary); + box-shadow: var(--tile-shadow); + border: 1px solid var(--tile-border-color); + animation: fadeIn 0.5s ease; +} + +.empty-state-icon { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; + background-color: rgba(0, 0, 0, 0.03); + padding: 24px; + border-radius: 50%; + transition: transform 0.3s ease; +} + +body.dark-mode .empty-state-icon { + background-color: rgba(255, 255, 255, 0.05); +} + +.empty-state:hover .empty-state-icon { + transform: scale(1.05) rotate(5deg); +} + +.empty-state svg { + width: 64px; + height: 64px; + color: var(--text-secondary); + opacity: 0.7; +} + +.empty-state h3 { + margin: 0 0 12px 0; + font-weight: 600; + font-size: 18px; + color: var(--text-color); +} + +.empty-state p { + margin: 0 0 24px 0; + font-size: 14px; + line-height: 1.6; + max-width: 80%; +} + +.empty-state-action { + margin-top: 8px; + padding: 8px 16px; } diff --git a/vscode-extension/webview-ui/src/App.tsx b/vscode-extension/webview-ui/src/App.tsx index 9ad0c6a..b36262a 100644 --- a/vscode-extension/webview-ui/src/App.tsx +++ b/vscode-extension/webview-ui/src/App.tsx @@ -4,6 +4,10 @@ import Annotation from "./Annotation"; import { tools, toolNames } from "./tools"; import React, { useState, useEffect, useRef } from "react"; import DarkModeToggle from "./DarkModeToggle"; +import Header from "./Header"; +import EmptyState from "./EmptyState"; +import AnnotationTile from "./AnnotationTile"; +import AddAnnotationBanner from "./AddAnnotationBanner"; interface AnnotationUpdate { document?: string; @@ -109,7 +113,10 @@ function AnnotationSidebarView(props: { annotationRefs && annotationRefs.current[closestAnnotationIndex] ) { - annotationRefs.current[closestAnnotationIndex]?.scrollIntoView({ behavior: "smooth" }); + annotationRefs.current[closestAnnotationIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest" + }); } } }, [charNum, annotations]); @@ -128,14 +135,14 @@ function AnnotationSidebarView(props: { if (startElement) { startElement.scrollIntoView({ behavior: "smooth", block: "center" }); } - + // Send message to jump to annotation in editor vscode.postMessage({ command: "jumpToAnnotation", data: { start: value.start, end: value.end, - annotationId: value.id // Add the annotation ID + annotationId: value.id } }); @@ -150,16 +157,16 @@ function AnnotationSidebarView(props: { const handleAnnotationUpdate = (id: string, value: AnnotationUpdate) => { console.log("Updating annotation:", id, value); - + const annotation = annotations.find(a => a.id === id); if (!annotation) return; - + const updatedAnnotation = { ...annotation, ...(value.document ? { document: value.document } : {}), ...(value.metadata ? { metadata: { ...annotation.metadata, ...value.metadata } } : {}) }; - + // Send update to extension vscode.postMessage({ command: "updateAnnotation", @@ -174,84 +181,50 @@ function AnnotationSidebarView(props: { onDeleteAnnotation(id); }; + if (annotations.length === 0) { + return ( + + + + + } + /> + ); + } + return ( - <> +
{[...annotations].sort(key((a: Annotation) => a.start)).map((annotation, index) => (
(annotationRefs.current[index] = ref)} - className={`annotation-tile ${ - props.selectedAnnotationId === annotation.id ? "selected" : "" - }`} - style={{ - display: "flex", - flexDirection: "column", - gap: "4px", - // use color from annotation metadata for left side if available - borderLeft: annotation.metadata?.color - ? `5px solid ${annotation.metadata.color}` - : "5px solid rgba(255,255,0,0.3)", - }} - onClick={handleClick(annotation.id)}> -
-
- Line {documentText.slice(0, annotation.start).split("\n").length} -
- - -
- {toolNames[annotation.tool as keyof typeof toolNames]} -
- {isAnnotationOutOfSync(annotation, documentText) && ( -
- Needs Retag -
- )} - -
- handleAnnotationUpdate(annotation.id, value)} - hoveredAnnotationId={props.hoveredAnnotationId} - setHoveredAnnotationId={props.setHoveredAnnotationId} - selectedAnnotationId={props.selectedAnnotationId} - setSelectedAnnotationId={props.setSelectedAnnotationId} - onDelete={onDeleteAnnotation} - /> + > + + handleAnnotationUpdate(annotation.id, value)} + hoveredAnnotationId={props.hoveredAnnotationId} + setHoveredAnnotationId={props.setHoveredAnnotationId} + selectedAnnotationId={props.selectedAnnotationId} + setSelectedAnnotationId={props.setSelectedAnnotationId} + onDelete={onDeleteAnnotation} + /> +
))} - +
); } @@ -259,19 +232,27 @@ function RetagBanner(props: { onRetag: () => void; }) { return ( -
- Document has been edited and some annotations need updating -
- +
+
+
+ + + + + + Document has been edited and some annotations need updating +
+
); } @@ -286,6 +267,8 @@ const isAnnotationOutOfSync = (annotation: Annotation, currentDocumentText: stri return currentDocumentText !== annotation.document || annotation.start === annotation.end; }; +// Kept for backwards compatibility, but no longer in use +// Instead using the enhanced AddAnnotationBanner component function AddNoteBanner(props: { onConfirm: () => void; selectedTool: string | undefined; @@ -296,54 +279,13 @@ function AddNoteBanner(props: { const { onConfirm, selectedTool, setSelectedTool, toolTypes } = props; return ( -
-
- Add Note: - -
-
- -
-
+ ); } @@ -695,109 +637,106 @@ function App() { if (!chooseAnnotationType) { return (
- - - {hasSelection && ( - - )} - - {showRetagBanner && ( - - )} - - - {!hasSelection && annotations.length === 0 && ( -

To add annotations, highlight text in the editor and choose a note type.

- )} +
+
+ {showRetagBanner && ( + + )} + + +
+ + {hasSelection && ( + + )} +
); } else { return (
- - -
-
Choose Annotation Type
-
- - - +
+ +
+
+
+
+

Choose Annotation Type

+

Select the type of annotation you would like to create

+ +
+ +
+ +
+ + +
+
+
+ +
- -
); } diff --git a/vscode-extension/webview-ui/src/DarkModeToggle.tsx b/vscode-extension/webview-ui/src/DarkModeToggle.tsx index bfbe0b8..9137bab 100644 --- a/vscode-extension/webview-ui/src/DarkModeToggle.tsx +++ b/vscode-extension/webview-ui/src/DarkModeToggle.tsx @@ -14,7 +14,7 @@ const DarkModeToggle: React.FC = ({ isDarkMode, toggleDarkM aria-label={isDarkMode ? "Switch to Light Mode" : "Switch to Dark Mode"} > {isDarkMode ? ( - + @@ -26,7 +26,7 @@ const DarkModeToggle: React.FC = ({ isDarkMode, toggleDarkM ) : ( - + )} diff --git a/vscode-extension/webview-ui/src/EmptyState.tsx b/vscode-extension/webview-ui/src/EmptyState.tsx new file mode 100644 index 0000000..e535675 --- /dev/null +++ b/vscode-extension/webview-ui/src/EmptyState.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +interface EmptyStateProps { + title: string; + message: string; + icon?: React.ReactNode; + actionButton?: { + label: string; + onClick: () => void; + }; +} + +const EmptyState: React.FC = ({ + title, + message, + icon, + actionButton +}) => { + const defaultIcon = ( + + + + + ); + + return ( +
+
+ {icon || defaultIcon} +
+

{title}

+

{message}

+ + {actionButton && ( + + )} +
+ ); +}; + +export default EmptyState; \ No newline at end of file diff --git a/vscode-extension/webview-ui/src/Header.tsx b/vscode-extension/webview-ui/src/Header.tsx new file mode 100644 index 0000000..112c8f0 --- /dev/null +++ b/vscode-extension/webview-ui/src/Header.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import DarkModeToggle from './DarkModeToggle'; + +interface HeaderProps { + isDarkMode: boolean; + toggleDarkMode: () => void; + onRetag?: () => void; + needsRetagging: boolean; +} + +const Header: React.FC = ({ + isDarkMode, + toggleDarkMode, + onRetag, + needsRetagging +}) => { + return ( +
+
+

+ + + + + Annotations +

+
+ +
+ {needsRetagging && onRetag && ( + + )} + +
+
+ ); +}; + +export default Header; \ No newline at end of file