diff --git a/apps/frontend/package.json b/apps/frontend/package.json index bcb392ae9..09165f7a2 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -60,6 +60,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lydell/node-pty": "^1.1.0", + "@modelcontextprotocol/sdk": "^1.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -97,7 +98,6 @@ "lucide-react": "^0.575.0", "minimatch": "^10.1.1", "motion": "^12.23.26", - "@modelcontextprotocol/sdk": "^1.0.4", "proper-lockfile": "^4.1.2", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -131,6 +131,7 @@ "@types/uuid": "^11.0.0", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.18", + "agentation": "^2.2.1", "autoprefixer": "^10.4.22", "cross-env": "^10.1.0", "electron": "40.6.0", diff --git a/apps/frontend/src/main/__tests__/annotation-to-spec-service.test.ts b/apps/frontend/src/main/__tests__/annotation-to-spec-service.test.ts new file mode 100644 index 000000000..2b86d13d7 --- /dev/null +++ b/apps/frontend/src/main/__tests__/annotation-to-spec-service.test.ts @@ -0,0 +1,502 @@ +/** + * Tests for AnnotationToSpecService + * + * Tests the annotation to spec conversion service including: + * - Annotation validation + * - Spec ID generation + * - Spec folder creation with files + * - Spec existence checking + * - Spec deletion + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { AnnotationToSpecService } from '../services/annotation-to-spec-service'; +import type { Annotation, AnnotationSubmissionResult } from '../../shared/types/annotation'; + +// Mock fs utilities +vi.mock('../fs-utils', () => ({ + atomicWriteFile: async (filePath: string, content: string) => { + // Ensure directory exists + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, content, 'utf-8'); + } +})); + +describe('AnnotationToSpecService', () => { + let service: AnnotationToSpecService; + let tempDir: string; + let projectPath: string; + + beforeEach(async () => { + service = new AnnotationToSpecService(); + + // Create temporary directory for testing + tempDir = path.join(process.env.TMPDIR || '/tmp', `annotation-spec-test-${Date.now()}`); + projectPath = tempDir; + + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('validateAnnotation (via createSpecFromAnnotation)', () => { + it('should reject annotation with empty ID', async () => { + const invalidAnnotation = { + id: '', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation ID is required'); + }); + + it('should reject annotation with empty description', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: '', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation description is required'); + }); + + it('should reject annotation with description too short (< 10 chars)', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Short', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation description must be at least 10 characters'); + }); + + it('should reject annotation without screenshot', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: '', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description that is long enough', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation screenshot is required'); + }); + + it('should reject annotation without coordinates', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: undefined as any, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description that is long enough', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation coordinates are required'); + }); + + it('should reject annotation with invalid coordinates (non-numeric)', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 'not a number' as any, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description that is long enough', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation coordinates must be valid numbers'); + }); + + it('should reject annotation with zero width', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 0, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description that is long enough', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation coordinates must have positive width and height'); + }); + + it('should reject annotation with zero height', async () => { + const invalidAnnotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 0 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Valid description that is long enough', + severity: 'low', + status: 'draft' + } as Annotation; + + await expect(service.createSpecFromAnnotation(projectPath, invalidAnnotation)) + .rejects.toThrow('Annotation coordinates must have positive width and height'); + }); + + it('should accept valid annotation', async () => { + const validAnnotation: Annotation = { + id: 'test-annotation-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 100, y: 200, width: 300, height: 400 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'This is a valid annotation description', + severity: 'medium', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, validAnnotation); + + expect(result).toBeDefined(); + expect(result.specId).toBeDefined(); + expect(result.annotation).toBeDefined(); + }); + }); + + describe('generateSpecId (via createSpecFromAnnotation)', () => { + it('should generate spec ID with first 8 chars of annotation UUID', async () => { + const annotation: Annotation = { + id: 'annotation-12345678-abcd-efgh-ijkl-mnopqrstuvwxyz', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test button alignment issue', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + expect(result.specId).toMatch(/^annotation-/); + expect(result.specId).toContain('test-button-alignment'); + }); + + it('should slugify description in spec ID', async () => { + const annotation: Annotation = { + id: 'test-id', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Fix the spacing issue!!! with multiple spaces', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + expect(result.specId).not.toContain(' '); + expect(result.specId).not.toContain('!!!'); + }); + }); + + describe('createSpecFromAnnotation', () => { + it('should create spec folder with spec.md file', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 100, y: 200, width: 300, height: 400 }, + viewportSize: { width: 1920, height: 1080, devicePixelRatio: 1 }, + description: 'Button text is not centered properly', + severity: 'high', + status: 'draft', + component: 'SubmitButton', + route: '/settings' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Check spec.md exists + const specMdPath = path.join(result.specPath, 'spec.md'); + const specMdContent = await fs.readFile(specMdPath, 'utf-8'); + + expect(specMdContent).toContain('Button text is not centered properly'); + expect(specMdContent).toContain('High'); + expect(specMdContent).toContain('SubmitButton'); + expect(specMdContent).toContain('/settings'); + expect(specMdContent).toContain('X: 100px'); + expect(specMdContent).toContain('Y: 200px'); + expect(specMdContent).toContain('Width: 300px'); + expect(specMdContent).toContain('Height: 400px'); + }); + + it('should create requirements.json file', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 50, y: 50, width: 200, height: 150 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Viewport scaling issue on mobile', + severity: 'medium', + status: 'draft', + route: '/dashboard' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Check requirements.json exists and has correct content + const requirementsPath = path.join(result.specPath, 'requirements.json'); + const requirementsContent = await fs.readFile(requirementsPath, 'utf-8'); + const requirements = JSON.parse(requirementsContent); + + expect(requirements.task_description).toBe('Viewport scaling issue on mobile'); + expect(requirements.severity).toBe('medium'); + expect(requirements.route).toBe('/dashboard'); + expect(requirements.coordinates).toEqual({ x: 50, y: 50, width: 200, height: 150 }); + expect(requirements.annotation_id).toBe('test-annotation'); + expect(requirements.generated_from).toBe('visual_annotation'); + }); + + it('should save screenshot.png from base64 data', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Check screenshot.png exists + const screenshotPath = path.join(result.specPath, 'screenshot.png'); + await fs.access(screenshotPath); + }); + + it('should handle screenshot without data URL prefix', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Should still save screenshot correctly + const screenshotPath = path.join(result.specPath, 'screenshot.png'); + await fs.access(screenshotPath); + }); + + it('should update annotation with spec ID and completed status', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + expect(annotation.specId).toBe(result.specId); + expect(annotation.status).toBe('completed'); + }); + + it('should support custom title in spec generation', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation, { + title: 'Custom Spec Title' + }); + + const specMdPath = path.join(result.specPath, 'spec.md'); + const specMdContent = await fs.readFile(specMdPath, 'utf-8'); + + expect(specMdContent).toContain('# Custom Spec Title'); + }); + + it('should skip screenshot when includeScreenshot is false', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const result = await service.createSpecFromAnnotation(projectPath, annotation, { + includeScreenshot: false + }); + + const screenshotPath = path.join(result.specPath, 'screenshot.png'); + + await expect(fs.access(screenshotPath)).rejects.toThrow(); + }); + }); + + describe('specExists', () => { + it('should return false for non-existent spec', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + const exists = await service.specExists(projectPath, annotation); + + expect(exists).toBe(false); + }); + + it('should return true for existing spec', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + // Create spec first + await service.createSpecFromAnnotation(projectPath, annotation); + + // Check if exists + const exists = await service.specExists(projectPath, annotation); + + expect(exists).toBe(true); + }); + }); + + describe('deleteSpec', () => { + it('should delete spec folder', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + // Create spec first + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Verify spec exists + await expect(fs.access(result.specPath)).resolves.toBeUndefined(); + + // Delete spec + await service.deleteSpec(projectPath, annotation); + + // Verify spec folder deleted + await expect(fs.access(result.specPath)).rejects.toThrow(); + }); + + it('should delete spec using spec ID from annotation', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + // Create spec (which sets specId on annotation) + const result = await service.createSpecFromAnnotation(projectPath, annotation); + + // Delete using annotation with specId + await service.deleteSpec(projectPath, annotation); + + // Verify spec folder deleted + await expect(fs.access(result.specPath)).rejects.toThrow(); + }); + + it('should not throw when deleting non-existent spec', async () => { + const annotation: Annotation = { + id: 'test-annotation', + timestamp: '2025-02-25T12:00:00Z', + screenshot: 'data:image/png;base64,abc123', + coordinates: { x: 0, y: 0, width: 100, height: 100 }, + viewportSize: { width: 1920, height: 1080 }, + description: 'Test annotation', + severity: 'low', + status: 'draft' + }; + + // Should not throw + await expect(service.deleteSpec(projectPath, annotation)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/annotation-handlers.ts b/apps/frontend/src/main/ipc-handlers/annotation-handlers.ts new file mode 100644 index 000000000..f34e90b7f --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/annotation-handlers.ts @@ -0,0 +1,371 @@ +/** + * Annotation IPC Handlers + * + * Handles IPC requests for annotation operations including create, list, get, and delete. + * Annotations are visual UX feedback captured during prototype development. + * + * Storage: Annotations are persisted to a JSON file in the app's userData directory. + */ + +import { ipcMain, app } from 'electron'; +import * as path from 'path'; +import { promises as fsPromises } from 'fs'; +import { v4 as uuidv4 } from 'uuid'; +import type { BrowserWindow } from 'electron'; + +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + Annotation, + AnnotationCreatePayload, + AnnotationDeletePayload, + AnnotationListPayload, + AnnotationSubmissionResult +} from '../../shared/types/annotation'; +import type { IPCResult } from '../../shared/types'; +import { annotationToSpecService } from '../services/annotation-to-spec-service'; +import { appLog } from '../app-logger'; + +/** + * Get the annotations storage file path + * Uses userData directory for app-specific data persistence + */ +function getAnnotationsPath(): string { + const userDataPath = app.getPath('userData'); + return path.join(userDataPath, 'annotations.json'); +} + +/** + * Load all annotations from storage + * Returns empty array if file doesn't exist or is invalid + */ +async function loadAnnotations(): Promise { + const annotationsPath = getAnnotationsPath(); + + try { + await fsPromises.access(annotationsPath); + const content = await fsPromises.readFile(annotationsPath, 'utf-8'); + const annotations = JSON.parse(content); + + // Validate array structure + if (!Array.isArray(annotations)) { + appLog.error('[Annotations] Invalid storage format: expected array'); + return []; + } + + return annotations as Annotation[]; + } catch (error) { + // File doesn't exist or is invalid - return empty array + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + appLog.error('[Annotations] Failed to load annotations:', error); + } + return []; + } +} + +/** + * Save annotations to storage + */ +async function saveAnnotationsToFile(annotations: Annotation[]): Promise { + const annotationsPath = getAnnotationsPath(); + await fsPromises.writeFile(annotationsPath, JSON.stringify(annotations, null, 2), 'utf-8'); +} + +/** + * Validate annotation create payload + * Returns array of validation error messages (empty if valid) + */ +function validateAnnotationPayload(payload: AnnotationCreatePayload): string[] { + const errors: string[] = []; + + if (!payload.description || payload.description.trim() === '') { + errors.push('Description is required'); + } + + if (payload.description && payload.description.length < 10) { + errors.push('Description must be at least 10 characters'); + } + + if (!payload.screenshot || payload.screenshot.trim() === '') { + errors.push('Screenshot is required'); + } + + if (!payload.coordinates) { + errors.push('Coordinates are required'); + } else { + const { x, y, width, height } = payload.coordinates; + if (typeof x !== 'number' || typeof y !== 'number' || + typeof width !== 'number' || typeof height !== 'number') { + errors.push('Coordinates must be valid numbers'); + } + + if (width <= 0 || height <= 0) { + errors.push('Coordinates must have positive width and height'); + } + } + + if (!payload.severity || !['low', 'medium', 'high', 'critical'].includes(payload.severity)) { + errors.push('Severity must be one of: low, medium, high, critical'); + } + + return errors; +} + +/** + * Create an annotation from capture data and viewport info + * Adds metadata like ID, timestamp, viewport size, and component detection + */ +function createAnnotationFromPayload( + payload: AnnotationCreatePayload, + viewportSize: { width: number; height: number; devicePixelRatio?: number }, + component?: string +): Annotation { + const now = new Date().toISOString(); + + return { + id: uuidv4(), + timestamp: now, + screenshot: payload.screenshot, + coordinates: payload.coordinates, + description: payload.description, + severity: payload.severity, + component, + route: payload.route, + viewportSize, + status: 'draft' + }; +} + +/** + * Register all annotation-related IPC handlers + * + * @param getMainWindow - Function to get the main BrowserWindow + */ +export function registerAnnotationHandlers( + _getMainWindow: () => BrowserWindow | null +): void { + /** + * Submit an annotation and optionally create a spec from it + */ + ipcMain.handle( + IPC_CHANNELS.ANNOTATION_SUBMIT, + async ( + _event, + projectId: string, + payload: AnnotationCreatePayload, + viewportSize: { width: number; height: number; devicePixelRatio?: number }, + component?: string, + createSpec?: boolean + ): Promise> => { + try { + appLog.log('[Annotation] Submitting annotation:', payload.description.substring(0, 50)); + + // Validate payload + const validationErrors = validateAnnotationPayload(payload); + if (validationErrors.length > 0) { + return { + success: false, + error: validationErrors.join('; ') + }; + } + + // Create annotation object + const annotation = createAnnotationFromPayload(payload, viewportSize, component); + + // Get project path from project store + const { projectStore } = await import('../project-store'); + const project = projectStore.getProject(projectId); + + if (!project) { + return { + success: false, + error: 'Project not found' + }; + } + + // Save annotation to storage + const annotations = await loadAnnotations(); + annotations.push(annotation); + await saveAnnotationsToFile(annotations); + + appLog.log('[Annotation] Saved annotation:', annotation.id); + + // If spec creation requested, use AnnotationToSpecService + if (createSpec) { + try { + annotation.status = 'processing'; + + const result = await annotationToSpecService.createSpecFromAnnotation( + project.path, + annotation + ); + + appLog.log('[Annotation] Created spec:', result.specId); + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + appLog.error('[Annotation] Failed to create spec:', error); + + // Update annotation status to failed + const annotations = await loadAnnotations(); + const index = annotations.findIndex(a => a.id === annotation.id); + if (index !== -1) { + annotations[index].status = 'failed'; + annotations[index].error = errorMessage; + await saveAnnotationsToFile(annotations); + } + + return { + success: false, + error: `Failed to create spec: ${errorMessage}` + }; + } + } + + // Return annotation without spec creation + return { + success: true, + data: { + annotation, + specId: '', + specPath: '' + } + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + appLog.error('[Annotation] Submit failed:', error); + return { + success: false, + error: errorMessage + }; + } + } + ); + + /** + * Get a single annotation by ID + */ + ipcMain.handle( + IPC_CHANNELS.ANNOTATION_GET, + async (_event, annotationId: string): Promise> => { + try { + appLog.log('[Annotation] Getting annotation:', annotationId); + + const annotations = await loadAnnotations(); + const annotation = annotations.find(a => a.id === annotationId); + + if (!annotation) { + return { + success: false, + error: `Annotation not found: ${annotationId}` + }; + } + + return { success: true, data: annotation }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + appLog.error('[Annotation] Get failed:', error); + return { + success: false, + error: errorMessage + }; + } + } + ); + + /** + * List annotations with optional filtering + */ + ipcMain.handle( + IPC_CHANNELS.ANNOTATION_LIST, + async (_event, payload?: AnnotationListPayload): Promise> => { + try { + appLog.log('[Annotation] Listing annotations:', payload); + + let annotations = await loadAnnotations(); + + // Apply status filter if provided + if (payload?.status) { + annotations = annotations.filter(a => a.status === payload.status); + } + + // Apply route filter if provided + if (payload?.route) { + annotations = annotations.filter(a => a.route === payload.route); + } + + // Sort by timestamp descending (newest first) + annotations.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + appLog.log('[Annotation] Returning', annotations.length, 'annotations'); + return { success: true, data: annotations }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + appLog.error('[Annotation] List failed:', error); + return { + success: false, + error: errorMessage + }; + } + } + ); + + /** + * Delete an annotation by ID + * Optionally deletes the generated spec folder as well + */ + ipcMain.handle( + IPC_CHANNELS.ANNOTATION_DELETE, + async ( + _event, + projectId: string, + payload: AnnotationDeletePayload, + deleteSpec?: boolean + ): Promise> => { + try { + appLog.log('[Annotation] Deleting annotation:', payload.id); + + const annotations = await loadAnnotations(); + const annotation = annotations.find(a => a.id === payload.id); + + if (!annotation) { + return { + success: false, + error: `Annotation not found: ${payload.id}` + }; + } + + // Delete spec folder if requested and annotation has a spec + if (deleteSpec && annotation.specId) { + try { + const { projectStore } = await import('../project-store'); + const project = projectStore.getProject(projectId); + + if (project) { + await annotationToSpecService.deleteSpec(project.path, annotation); + appLog.log('[Annotation] Deleted spec:', annotation.specId); + } + } catch (error) { + appLog.error('[Annotation] Failed to delete spec:', error); + // Continue with annotation deletion even if spec deletion fails + } + } + + // Remove annotation from storage + const filteredAnnotations = annotations.filter(a => a.id !== payload.id); + await saveAnnotationsToFile(filteredAnnotations); + + appLog.log('[Annotation] Deleted annotation:', payload.id); + return { success: true, data: undefined }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + appLog.error('[Annotation] Delete failed:', error); + return { + success: false, + error: errorMessage + }; + } + } + ); + + appLog.log('[Annotation] IPC handlers registered'); +} diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index 9b4d7327e..892201acd 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -43,6 +43,7 @@ import { registerTemplateHandlers } from './template-handlers'; import { registerPatternHandlers } from './pattern-handlers'; import { registerSessionReplayHandlers } from './session-replay-handlers'; import { registerFeedbackHandlers } from './feedback-handlers'; +import { registerAnnotationHandlers } from './annotation-handlers'; import { notificationService } from '../notification-service'; import { setAgentManagerRef } from './utils'; @@ -159,6 +160,9 @@ export function setupIpcHandlers( // Feedback handlers (adaptive agent learning) registerFeedbackHandlers(getMainWindow); + // Annotation handlers (UX feedback loop) + registerAnnotationHandlers(getMainWindow); + // Scheduler handlers (build scheduling and queue management) registerSchedulerHandlers(getMainWindow); @@ -198,5 +202,6 @@ export { registerPatternHandlers, registerSessionReplayHandlers, registerFeedbackHandlers, + registerAnnotationHandlers, registerSchedulerHandlers }; diff --git a/apps/frontend/src/main/mcp-server.ts b/apps/frontend/src/main/mcp-server.ts index 7e9b9e3fc..2d7d7b371 100644 --- a/apps/frontend/src/main/mcp-server.ts +++ b/apps/frontend/src/main/mcp-server.ts @@ -93,6 +93,17 @@ interface CommandResult { error?: string; } +/** Annotation task data */ +interface AnnotationTask { + id: string; + element_selector: string; + annotation_type: string; + message: string; + priority: string; + context?: string; + timestamp: number; +} + // ============================================================================ // Zod Schemas (Input Validation) // ============================================================================ @@ -150,6 +161,22 @@ const readLogsSchema = z.object({ since: z.number().optional() }); +/** + * Schema for create_annotation_task tool + * - element_selector: CSS selector for element to annotate + * - annotation_type: Type of annotation (comment, suggestion, issue, question) + * - message: Annotation message content + * - priority: Optional priority level (low, medium, high) + * - context: Optional additional context + */ +const createAnnotationTaskSchema = z.object({ + element_selector: z.string().max(1000), + annotation_type: z.enum(['comment', 'suggestion', 'issue', 'question']), + message: z.string().min(1).max(5000), + priority: z.enum(['low', 'medium', 'high']).optional(), + context: z.string().max(2000).optional() +}); + // ============================================================================ // Log Collector // ============================================================================ @@ -515,7 +542,15 @@ export class ElectronMCPServer { async (params: z.infer) => this.readLogs(params) ); - // Tool 5: health_check + // Tool 5: create_annotation_task + this.server.tool( + 'create_annotation_task', + 'Create an annotation task for UX feedback on UI elements', + createAnnotationTaskSchema.shape, + async (params: z.infer) => this.createAnnotationTask(params) + ); + + // Tool 6: health_check this.server.tool( 'health_check', 'Get MCP server health metrics', @@ -523,7 +558,7 @@ export class ElectronMCPServer { async () => this.healthCheck() ); - console.log('[MCP] Registered 5 tools: get_window_info, take_screenshot, send_command, read_logs, health_check'); + console.log('[MCP] Registered 6 tools: get_window_info, take_screenshot, send_command, read_logs, create_annotation_task, health_check'); } /** @@ -701,6 +736,54 @@ export class ElectronMCPServer { }] }; } + + /** + * Tool: create_annotation_task + * Creates an annotation task for UX feedback on UI elements + */ + private async createAnnotationTask(params: z.infer): Promise { + try { + // Validate input + const validated = createAnnotationTaskSchema.parse(params); + + // Rate limit check + if (!this.rateLimiter.canExecute('create_annotation_task', 20, 1000)) { + throw new Error('Rate limit exceeded for create_annotation_task'); + } + + // Generate unique task ID + const taskId = `annotation-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // Create annotation task + const task: AnnotationTask = { + id: taskId, + element_selector: validated.element_selector, + annotation_type: validated.annotation_type, + message: validated.message, + priority: validated.priority ?? 'medium', + context: validated.context, + timestamp: Date.now() + }; + + // Send annotation task to renderer via IPC + const win = BrowserWindow.getFocusedWindow(); + if (win) { + win.webContents.send('annotation-task-created', task); + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + task: task + }, null, 2) + }] + }; + } catch (error) { + return toolErrorResult(error, { success: false }); + } + } } // ============================================================================ diff --git a/apps/frontend/src/main/services/annotation-to-spec-service.ts b/apps/frontend/src/main/services/annotation-to-spec-service.ts new file mode 100644 index 000000000..ab06576a3 --- /dev/null +++ b/apps/frontend/src/main/services/annotation-to-spec-service.ts @@ -0,0 +1,321 @@ +/** + * Annotation-to-Spec Service + * + * Transforms visual UI annotations into Auto-Claude spec folder structure. + * Creates spec folders with spec.md, requirements.json, and screenshot.png + * from annotation data submitted via the visual feedback system. + */ + +import { promises as fsPromises } from 'fs'; +import * as path from 'path'; +import { atomicWriteFile } from '../fs-utils'; +import { AUTO_BUILD_PATHS } from '../../shared/constants'; +import type { + Annotation, + AnnotationSpecOptions, + AnnotationSubmissionResult +} from '../../shared/types/annotation'; + +/** + * Service class for converting annotations to spec folders + */ +export class AnnotationToSpecService { + /** + * Convert annotation to URL-friendly slug + * Converts description to kebab-case for use in folder names + */ + private slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special characters + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens + .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens + } + + /** + * Generate spec ID from annotation + * Format: {number}-annotation-{slug} + * Uses first 8 chars of UUID as number for uniqueness + */ + private generateSpecId(annotation: Annotation): string { + const shortId = annotation.id.substring(0, 8); + const slug = this.slugify(annotation.description); + return `${shortId}-annotation-${slug}`; + } + + /** + * Generate spec.md content from annotation + */ + private generateSpecMd(annotation: Annotation, options?: AnnotationSpecOptions): string { + const title = options?.title || `Fix: ${annotation.description}`; + const severityMap: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + critical: 'Critical' + }; + + const coordinates = annotation.coordinates; + const viewport = annotation.viewportSize; + + let content = `# ${title}\n\n`; + + content += `## Overview\n\n`; + content += `This spec was generated from a visual UI annotation submitted during prototype development.\n\n`; + + content += `### Issue Details\n\n`; + content += `- **Description**: ${annotation.description}\n`; + content += `- **Severity**: ${severityMap[annotation.severity]}\n`; + content += `- **Component**: ${annotation.component || 'Unknown'}\n`; + content += `- **Route**: ${annotation.route || 'Unknown'}\n\n`; + + content += `### Screen Context\n\n`; + content += `The issue was identified at the following screen coordinates:\n\n`; + content += `- **X**: ${coordinates.x}px\n`; + content += `- **Y**: ${coordinates.y}px\n`; + content += `- **Width**: ${coordinates.width}px\n`; + content += `- **Height**: ${coordinates.height}px\n\n`; + + content += `**Viewport Size**:\n`; + content += `- **Width**: ${viewport.width}px\n`; + content += `- **Height**: ${viewport.height}px\n`; + if (viewport.devicePixelRatio) { + content += `- **Device Pixel Ratio**: ${viewport.devicePixelRatio}\n`; + } + content += `\n`; + + content += `## Workflow Type\n\n`; + content += `**Type**: bug_fix\n\n`; + content += `**Rationale**: This issue was identified through visual annotation during prototype development, indicating a UI/UX problem that needs to be addressed.\n\n`; + + content += `## Task Scope\n\n`; + content += `### This Task Will:\n`; + content += `- [ ] Investigate and fix the reported UI issue\n`; + content += `- [ ] Verify the fix works at the reported viewport size\n`; + content += `- [ ] Ensure no regressions in related functionality\n\n`; + + content += `### Out of Scope:\n`; + content += `- Feature additions beyond fixing the reported issue\n`; + content += `- Changes to unrelated components or routes\n\n`; + + content += `## Service Context\n\n`; + content += `### Frontend (Primary Service)\n\n`; + content += `**Tech Stack:**\n`; + content += `- Language: TypeScript\n`; + content += `- Framework: React 19.x + Electron 40.6.0\n`; + content += `- Styling: Tailwind CSS\n\n`; + + content += `**Issue Location:**\n`; + if (annotation.component) { + content += `- Component: ${annotation.component}\n`; + } + if (annotation.route) { + content += `- Route: ${annotation.route}\n`; + } + content += `- Coordinates: (${coordinates.x}, ${coordinates.y}) size ${coordinates.width}x${coordinates.height}\n\n`; + + content += `## Requirements\n\n`; + content += `### Functional Requirements\n\n`; + content += `1. **Fix the Issue**\n`; + content += ` - Description: ${annotation.description}\n`; + content += ` - Acceptance: Issue is resolved, UI behaves correctly\n\n`; + + if (annotation.severity === 'critical' || annotation.severity === 'high') { + content += `2. **No Regressions**\n`; + content += ` - Description: Related functionality continues to work correctly\n`; + content += ` - Acceptance: All related features pass tests\n\n`; + } + + content += `### Non-Functional Requirements\n\n`; + content += `1. **Performance**\n`; + content += ` - Description: Fix should not impact app performance\n`; + content += ` - Acceptance: No perceptible lag when interacting with fixed component\n\n`; + + content += `2. **Cross-Platform Compatibility**\n`; + content += ` - Description: Fix works on Windows, macOS, and Linux\n`; + content += ` - Acceptance: Verified on all platforms\n\n`; + + content += `## Edge Cases\n\n`; + content += `1. **Viewport Sizes** - Ensure fix works at different viewport sizes\n`; + content += `2. **High DPI Displays** - Verify rendering on retina/high-DPI screens\n`; + if (annotation.severity === 'critical') { + content += `3. **Error Handling** - Component handles errors gracefully\n`; + } + content += `\n`; + + content += `## Success Criteria\n\n`; + content += `The task is complete when:\n\n`; + content += `1. [ ] The reported issue is resolved\n`; + content += `2. [ ] Visual inspection shows correct behavior\n`; + content += `3. [ ] No regressions in related functionality\n`; + content += `4. [ ] All existing tests still pass\n`; + content += `5. [ ] Works on Windows, macOS, and Linux\n\n`; + + content += `## Attachment\n\n`; + content += `A screenshot of the annotated area has been included as \`screenshot.png\` for reference.\n`; + + return content; + } + + /** + * Generate requirements.json content from annotation + */ + private generateRequirementsJson( + annotation: Annotation, + specId: string + ): string { + const requirements = { + task_description: annotation.description, + severity: annotation.severity, + component: annotation.component || null, + route: annotation.route || null, + coordinates: annotation.coordinates, + viewport_size: annotation.viewportSize, + annotation_id: annotation.id, + annotation_timestamp: annotation.timestamp, + generated_from: 'visual_annotation', + spec_id: specId + }; + + return JSON.stringify(requirements, null, 2); + } + + /** + * Save screenshot from base64 string + */ + private async saveScreenshot( + specDir: string, + base64Screenshot: string + ): Promise { + const screenshotPath = path.join(specDir, 'screenshot.png'); + + // Strip data URL prefix if present (e.g., "data:image/png;base64,") + const base64Data = base64Screenshot.replace(/^data:image\/png;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + await fsPromises.writeFile(screenshotPath, buffer); + } + + /** + * Validate annotation before creating spec + * Throws error if annotation is invalid + */ + private validateAnnotation(annotation: Annotation): void { + if (!annotation.id || annotation.id.trim() === '') { + throw new Error('Annotation ID is required'); + } + + if (!annotation.description || annotation.description.trim() === '') { + throw new Error('Annotation description is required'); + } + + if (annotation.description.length < 10) { + throw new Error('Annotation description must be at least 10 characters'); + } + + if (!annotation.screenshot || annotation.screenshot.trim() === '') { + throw new Error('Annotation screenshot is required'); + } + + if (!annotation.coordinates) { + throw new Error('Annotation coordinates are required'); + } + + const { x, y, width, height } = annotation.coordinates; + if (typeof x !== 'number' || typeof y !== 'number' || + typeof width !== 'number' || typeof height !== 'number') { + throw new Error('Annotation coordinates must be valid numbers'); + } + + if (width <= 0 || height <= 0) { + throw new Error('Annotation coordinates must have positive width and height'); + } + } + + /** + * Create spec folder from annotation + * + * Creates a new spec folder in .auto-claude/specs/ with: + * - spec.md: Generated spec document + * - requirements.json: Annotation data as requirements + * - screenshot.png: Captured screenshot + * + * @param projectPath - Root path of the project + * @param annotation - The annotation to convert + * @param options - Optional generation options + * @returns Promise Result with spec ID and path + */ + async createSpecFromAnnotation( + projectPath: string, + annotation: Annotation, + options?: AnnotationSpecOptions + ): Promise { + // Validate annotation before proceeding + this.validateAnnotation(annotation); + + // Generate spec ID + const specId = this.generateSpecId(annotation); + + // Create spec directory path + const specsDir = path.join(projectPath, AUTO_BUILD_PATHS.SPECS_DIR); + const specDir = path.join(specsDir, specId); + + // Ensure spec directory exists + await fsPromises.mkdir(specDir, { recursive: true }); + + // Generate and write spec.md + const specMdContent = this.generateSpecMd(annotation, options); + const specMdPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + await atomicWriteFile(specMdPath, specMdContent); + + // Generate and write requirements.json + const requirementsContent = this.generateRequirementsJson(annotation, specId); + const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); + await atomicWriteFile(requirementsPath, requirementsContent); + + // Save screenshot if not explicitly disabled + if (options?.includeScreenshot !== false) { + await this.saveScreenshot(specDir, annotation.screenshot); + } + + // Update annotation with spec ID + annotation.specId = specId; + annotation.status = 'completed'; + + return { + annotation, + specId, + specPath: specDir + }; + } + + /** + * Check if a spec directory exists for an annotation + */ + async specExists(projectPath: string, annotation: Annotation): Promise { + const specId = this.generateSpecId(annotation); + const specDir = path.join(projectPath, AUTO_BUILD_PATHS.SPECS_DIR, specId); + + try { + await fsPromises.access(specDir); + return true; + } catch { + return false; + } + } + + /** + * Delete a spec directory created from an annotation + * Use with caution - this permanently deletes the spec folder + */ + async deleteSpec(projectPath: string, annotation: Annotation): Promise { + const specId = annotation.specId || this.generateSpecId(annotation); + const specDir = path.join(projectPath, AUTO_BUILD_PATHS.SPECS_DIR, specId); + + await fsPromises.rm(specDir, { recursive: true, force: true }); + } +} + +// Singleton instance +export const annotationToSpecService = new AnnotationToSpecService(); diff --git a/apps/frontend/src/preload/api/annotation-api.ts b/apps/frontend/src/preload/api/annotation-api.ts new file mode 100644 index 000000000..a8f37ae24 --- /dev/null +++ b/apps/frontend/src/preload/api/annotation-api.ts @@ -0,0 +1,278 @@ +/** + * Annotation API + * + * Provides annotation functionality for visual UX feedback loop. + * Allows developers to annotate UI defects and gaps directly on the running prototype. + * Annotations can be automatically converted into actionable tasks/specs. + */ +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import { ipcRenderer } from 'electron'; +import type { + Annotation, + AnnotationCreatePayload, + AnnotationDeletePayload, + AnnotationListPayload, + AnnotationSubmissionResult, + AnnotationViewport, + AnnotationStatus +} from '../../shared/types/annotation'; + +/** + * Annotation creation request + */ +export interface AnnotationCreateRequest { + /** Project ID for the annotation */ + projectId: string; + /** Annotation data */ + payload: AnnotationCreatePayload; + /** Viewport dimensions at time of annotation */ + viewportSize: AnnotationViewport; + /** Auto-detected component name (optional) */ + component?: string; +} + +/** + * Annotation submission request (with optional spec creation) + */ +export interface AnnotationSubmitRequest extends AnnotationCreateRequest { + /** Whether to create a spec from this annotation */ + createSpec?: boolean; +} + +/** + * Annotation list request + */ +export interface AnnotationListRequest { + /** Optional filter by status */ + status?: string; + /** Optional filter by route */ + route?: string; +} + +/** + * Annotation delete request + */ +export interface AnnotationDeleteRequest { + /** Project ID for the annotation */ + projectId: string; + /** Delete payload with annotation ID */ + payload: AnnotationDeletePayload; + /** Whether to also delete the generated spec */ + deleteSpec?: boolean; +} + +/** + * Annotation API interface + */ +export interface AnnotationAPI { + /** Create a new annotation (without spec creation) */ + createAnnotation: (request: AnnotationCreateRequest) => Promise<{ + success: boolean; + data?: AnnotationSubmissionResult; + error?: string; + }>; + + /** Submit an annotation (optionally with spec creation) */ + submitAnnotation: (request: AnnotationSubmitRequest) => Promise<{ + success: boolean; + data?: AnnotationSubmissionResult; + error?: string; + }>; + + /** Get a single annotation by ID */ + getAnnotation: (annotationId: string) => Promise<{ + success: boolean; + data?: Annotation; + error?: string; + }>; + + /** List annotations with optional filtering */ + listAnnotations: (request?: AnnotationListRequest) => Promise<{ + success: boolean; + data?: Annotation[]; + error?: string; + }>; + + /** Delete an annotation by ID */ + deleteAnnotation: (request: AnnotationDeleteRequest) => Promise<{ + success: boolean; + error?: string; + }>; +} + +/** + * Validate annotation create request + */ +function validateAnnotationRequest(request: AnnotationCreateRequest): string[] { + const errors: string[] = []; + + if (!request.projectId || request.projectId.trim() === '') { + errors.push('Project ID is required'); + } + + if (!request.payload) { + errors.push('Payload is required'); + return errors; + } + + const { payload } = request; + + if (!payload.description || payload.description.trim() === '') { + errors.push('Description is required'); + } + + if (payload.description && payload.description.length < 10) { + errors.push('Description must be at least 10 characters'); + } + + if (!payload.screenshot || payload.screenshot.trim() === '') { + errors.push('Screenshot is required'); + } + + if (!payload.coordinates) { + errors.push('Coordinates are required'); + } else { + const { x, y, width, height } = payload.coordinates; + if (typeof x !== 'number' || typeof y !== 'number' || + typeof width !== 'number' || typeof height !== 'number') { + errors.push('Coordinates must be valid numbers'); + } + + if (width <= 0 || height <= 0) { + errors.push('Coordinates must have positive width and height'); + } + } + + if (!payload.severity || !['low', 'medium', 'high', 'critical'].includes(payload.severity)) { + errors.push('Severity must be one of: low, medium, high, critical'); + } + + return errors; +} + +/** + * Sanitize and cap field lengths to prevent large payloads + */ +function sanitizeAnnotationRequest(request: AnnotationCreateRequest): AnnotationCreateRequest { + return { + projectId: request.projectId.trim().slice(0, 128), + payload: { + screenshot: request.payload.screenshot.slice(0, 5000000), // Max 5MB for base64 screenshot + coordinates: request.payload.coordinates, + description: request.payload.description.trim().slice(0, 2048), + severity: request.payload.severity, + route: request.payload.route?.trim().slice(0, 256) + }, + viewportSize: request.viewportSize, + component: request.component?.trim().slice(0, 128) + }; +} + +/** + * Create the annotation API + */ +export const createAnnotationAPI = (): AnnotationAPI => ({ + createAnnotation: (rawRequest) => { + // Validate request + const validationErrors = validateAnnotationRequest(rawRequest); + if (validationErrors.length > 0) { + return Promise.resolve({ + success: false, + error: validationErrors.join('; ') + }); + } + + // Sanitize request + const request = sanitizeAnnotationRequest(rawRequest); + + // Call IPC handler without spec creation + return ipcRenderer.invoke( + IPC_CHANNELS.ANNOTATION_SUBMIT, + request.projectId, + request.payload, + request.viewportSize, + request.component, + false // Don't create spec + ); + }, + + submitAnnotation: (rawRequest) => { + // Validate request + const validationErrors = validateAnnotationRequest(rawRequest); + if (validationErrors.length > 0) { + return Promise.resolve({ + success: false, + error: validationErrors.join('; ') + }); + } + + // Sanitize request + const request = sanitizeAnnotationRequest(rawRequest); + + // Call IPC handler with optional spec creation + return ipcRenderer.invoke( + IPC_CHANNELS.ANNOTATION_SUBMIT, + request.projectId, + request.payload, + request.viewportSize, + request.component, + rawRequest.createSpec ?? false // Default to false + ); + }, + + getAnnotation: (annotationId) => { + if (!annotationId || annotationId.trim() === '') { + return Promise.resolve({ + success: false, + error: 'Annotation ID is required' + }); + } + + return ipcRenderer.invoke(IPC_CHANNELS.ANNOTATION_GET, annotationId.trim()); + }, + + listAnnotations: (request) => { + const payload: AnnotationListPayload = {}; + + if (request?.status) { + payload.status = request.status as AnnotationStatus; + } + + if (request?.route) { + payload.route = request.route.trim().slice(0, 256); + } + + return ipcRenderer.invoke(IPC_CHANNELS.ANNOTATION_LIST, payload); + }, + + deleteAnnotation: (rawRequest) => { + if (!rawRequest.projectId || rawRequest.projectId.trim() === '') { + return Promise.resolve({ + success: false, + error: 'Project ID is required' + }); + } + + if (!rawRequest.payload?.id || rawRequest.payload.id.trim() === '') { + return Promise.resolve({ + success: false, + error: 'Annotation ID is required' + }); + } + + const request: AnnotationDeleteRequest = { + projectId: rawRequest.projectId.trim().slice(0, 128), + payload: { + id: rawRequest.payload.id.trim() + }, + deleteSpec: rawRequest.deleteSpec ?? false + }; + + return ipcRenderer.invoke( + IPC_CHANNELS.ANNOTATION_DELETE, + request.projectId, + request.payload, + request.deleteSpec + ); + } +}); diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 6448d53ee..2b871997b 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -24,6 +24,7 @@ import { createSessionReplayAPI } from './modules/session-replay-api'; import { ContextViewerAPI, createContextViewerAPI } from './modules/context-viewer-api'; import { SchedulerAPI, createSchedulerAPI } from './scheduler-api'; import { FeedbackAPI, createFeedbackAPI } from './feedback-api'; +import { AnnotationAPI, createAnnotationAPI } from './annotation-api'; export interface ElectronAPI extends ProjectAPI, @@ -44,7 +45,8 @@ export interface ElectronAPI extends ScreenshotAPI, PluginAPI, ContextViewerAPI, - FeedbackAPI { + FeedbackAPI, + AnnotationAPI { github: GitHubAPI; /** Queue routing API for rate limit recovery */ queue: QueueAPI; @@ -73,6 +75,7 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createPluginAPI(), ...createContextViewerAPI(), ...createFeedbackAPI(), + ...createAnnotationAPI(), github: createGitHubAPI(), queue: createQueueAPI(), // Queue routing for rate limit recovery pattern: createPatternAPI(), @@ -103,7 +106,8 @@ export { createSessionReplayAPI, createContextViewerAPI, createSchedulerAPI, - createFeedbackAPI + createFeedbackAPI, + createAnnotationAPI }; export type { @@ -130,5 +134,6 @@ export type { SessionReplayAPI, ContextViewerAPI, SchedulerAPI, - FeedbackAPI + FeedbackAPI, + AnnotationAPI }; diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 93b3c455d..d71b06386 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -79,6 +79,14 @@ import { ProjectTabBar } from './components/ProjectTabBar'; import { AddProjectModal } from './components/AddProjectModal'; import { ViewStateProvider } from './contexts/ViewStateContext'; import { CommandPalette, type CommandAction } from './components/CommandPalette'; +import { + AnnotationOverlay, + AnnotationForm, + AnnotationList, + AnnotationToggle +} from './components/annotations'; +import { useAnnotationStore } from './stores/annotation-store'; +import type { AnnotationFormData } from '../shared/types/annotation'; // Version constant for version-specific warnings (e.g., reauthentication notices) const VERSION_WARNING_275 = '2.7.5'; @@ -179,6 +187,20 @@ export function App() { // Quick actions store const recentActions = useQuickActionsStore((state) => state.recentActions); + // Annotation store (dev-only) + const isAnnotationMode = useAnnotationStore((state) => state.isAnnotationMode); + const draftAnnotation = useAnnotationStore((state) => state.draftAnnotation); + const annotations = useAnnotationStore((state) => state.annotations); + const submitDraftAnnotation = useAnnotationStore((state) => state.submitDraftAnnotation); + const cancelDraftAnnotation = useAnnotationStore((state) => state.cancelDraftAnnotation); + const deleteAnnotation = useAnnotationStore((state) => state.deleteAnnotation); + const updateAnnotation = useAnnotationStore((state) => state.updateAnnotation); + const [annotationStatusFilter, setAnnotationStatusFilter] = useState<'all' | 'draft' | 'submitted' | 'processing' | 'completed' | 'failed'>('all'); + const [isAnnotationListOpen, setIsAnnotationListOpen] = useState(false); + + // Current route context for annotations + const currentRoute = activeView; + // Setup drag sensors const sensors = useSensors( useSensor(PointerSensor, { @@ -944,6 +966,33 @@ export function App() { return groups; }, [recentCommandActions, commandActions]); + // Annotation handlers (dev-only) + const handleAnnotationSubmit = async (formData: AnnotationFormData) => { + await submitDraftAnnotation(formData); + }; + + const handleAnnotationCancel = () => { + cancelDraftAnnotation(); + }; + + const handleAnnotationDelete = async (annotationId: string) => { + await deleteAnnotation(annotationId); + return true; + }; + + const handleAnnotationEdit = async (annotationId: string, newDescription: string) => { + await updateAnnotation(annotationId, { description: newDescription }); + return true; + }; + + const handleAnnotationRegenerate = async (annotationId: string) => { + // Re-submit annotation to regenerate spec + const annotation = annotations.find(a => a.id === annotationId); + if (annotation) { + await updateAnnotation(annotationId, { status: 'submitted' }); + } + }; + return ( @@ -992,6 +1041,31 @@ export function App() { )} + {/* Dev-only annotation toolbar */} + {process.env.NODE_ENV === 'development' && ( +
+
+ DEV TOOLS +
+ + +
+ {isAnnotationMode && ( +
+ + Annotation mode active +
+ )} +
+ )} + {/* Main content area */}
{selectedProject ? ( @@ -1332,6 +1406,38 @@ export function App() { commandGroups={commandGroups} /> + {/* Annotation Components - Development Only */} + {process.env.NODE_ENV === 'development' && ( + <> + {/* Annotation Overlay - visual selection when annotation mode is active */} + + + {/* Annotation Form - appears when draft annotation exists */} + {draftAnnotation && ( + + )} + + {/* Annotation List - dialog showing all annotations */} + + + + + + + )} + {/* Toast notifications */}
diff --git a/apps/frontend/src/renderer/components/__tests__/annotation-form.test.tsx b/apps/frontend/src/renderer/components/__tests__/annotation-form.test.tsx new file mode 100644 index 000000000..1cc179aef --- /dev/null +++ b/apps/frontend/src/renderer/components/__tests__/annotation-form.test.tsx @@ -0,0 +1,512 @@ +/** + * Tests for AnnotationForm component + * + * Tests the annotation form dialog including: + * - Form validation (description min length) + * - Severity selector functionality + * - Submit/cancel button states + * - Character counter updates + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AnnotationForm } from '../annotations/AnnotationForm'; +import type { AnnotationFormData, AnnotationCoordinates } from '../../../shared/types/annotation'; + +// Mock i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => defaultValue || key + }) +})); + +// Mock UI components +vi.mock('../ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: any) => ( + open ?
{children}
: null + ), + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogFooter: ({ children }: any) =>
{children}
+})); + +vi.mock('../ui/button', () => ({ + Button: ({ children, onClick, disabled, ...props }: any) => ( + + ) +})); + +vi.mock('../ui/textarea', () => ({ + Textarea: ({ value, onChange, disabled, placeholder, ...props }: any) => ( +