diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index b3ee5721..db04f7af 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -32,6 +32,7 @@ import { registerDebugHandlers } from './debug-handlers'; import { registerClaudeCodeHandlers } from './claude-code-handlers'; import { registerMcpHandlers } from './mcp-handlers'; import { registerProfileHandlers } from './profile-handlers'; +import { registerScreenshotHandlers } from './screenshot-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; import { notificationService } from '../notification-service'; @@ -118,6 +119,9 @@ export function setupIpcHandlers( // API Profile handlers (custom Anthropic-compatible endpoints) registerProfileHandlers(); + // Screenshot capture handlers + registerScreenshotHandlers(); + console.warn('[IPC] All handler modules registered successfully'); } @@ -144,5 +148,6 @@ export { registerDebugHandlers, registerClaudeCodeHandlers, registerMcpHandlers, - registerProfileHandlers + registerProfileHandlers, + registerScreenshotHandlers }; diff --git a/apps/frontend/src/main/ipc-handlers/screenshot-handlers.ts b/apps/frontend/src/main/ipc-handlers/screenshot-handlers.ts new file mode 100644 index 00000000..5c7a4980 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/screenshot-handlers.ts @@ -0,0 +1,94 @@ +/** + * Screenshot IPC Handlers + * + * Provides screenshot capture functionality using Electron's desktopCapturer API. + * Users can capture screenshots of their entire screen or individual application windows. + */ +import { ipcMain } from 'electron'; +import { desktopCapturer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import type { ScreenshotSource, ScreenshotCaptureOptions } from '../../shared/types/screenshot'; + +/** + * Register screenshot capture handlers + */ +export function registerScreenshotHandlers(): void { + /** + * Get available screenshot sources (screens and windows) + */ + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_GET_SOURCES, async () => { + try { + const sources = await desktopCapturer.getSources({ + types: ['screen', 'window'], + thumbnailSize: { + width: 320, + height: 240 + } + }); + + return { + success: true, + data: sources.map((source): ScreenshotSource => ({ + id: source.id, + name: source.name, + thumbnail: source.thumbnail.toDataURL() + })) + }; + } catch (error) { + console.error('Failed to get screenshot sources:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get screenshot sources' + }; + } + }); + + /** + * Capture screenshot from selected source + * Returns full resolution screenshot as base64 PNG + */ + ipcMain.handle(IPC_CHANNELS.SCREENSHOT_CAPTURE, async (_event, options: ScreenshotCaptureOptions) => { + // Validate sourceId parameter + if (!options?.sourceId || typeof options.sourceId !== 'string') { + return { + success: false, + error: 'Invalid sourceId parameter' + }; + } + + try { + const sources = await desktopCapturer.getSources({ + types: ['screen', 'window'], + thumbnailSize: { + // Capture at 2x resolution for retina display support + width: 3840, + height: 2160 + } + }); + + const selectedSource = sources.find(s => s.id === options.sourceId); + if (!selectedSource) { + return { + success: false, + error: 'Source not found' + }; + } + + // Return the thumbnail which is our high-res capture + const dataUrl = selectedSource.thumbnail.toDataURL(); + + return { + success: true, + data: dataUrl + }; + } catch (error) { + console.error('Failed to capture screenshot:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to capture screenshot' + }; + } + }); + + console.warn('[IPC] Screenshot handlers registered'); +} diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 72853177..8c78e4f0 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -13,6 +13,7 @@ import { DebugAPI, createDebugAPI } from './modules/debug-api'; import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; import { McpAPI, createMcpAPI } from './modules/mcp-api'; import { ProfileAPI, createProfileAPI } from './profile-api'; +import { ScreenshotAPI, createScreenshotAPI } from './screenshot-api'; export interface ElectronAPI extends ProjectAPI, @@ -28,7 +29,8 @@ export interface ElectronAPI extends DebugAPI, ClaudeCodeAPI, McpAPI, - ProfileAPI { + ProfileAPI, + ScreenshotAPI { github: GitHubAPI; } @@ -44,6 +46,7 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createClaudeCodeAPI(), ...createMcpAPI(), ...createProfileAPI(), + ...createScreenshotAPI(), github: createGitHubAPI() }); @@ -61,7 +64,8 @@ export { createGitHubAPI, createDebugAPI, createClaudeCodeAPI, - createMcpAPI + createMcpAPI, + createScreenshotAPI }; export type { @@ -79,5 +83,6 @@ export type { GitLabAPI, DebugAPI, ClaudeCodeAPI, - McpAPI + McpAPI, + ScreenshotAPI }; diff --git a/apps/frontend/src/preload/api/screenshot-api.ts b/apps/frontend/src/preload/api/screenshot-api.ts new file mode 100644 index 00000000..19e5c6a8 --- /dev/null +++ b/apps/frontend/src/preload/api/screenshot-api.ts @@ -0,0 +1,30 @@ +/** + * Screenshot API + * + * Provides screenshot capture functionality via IPC to the main process. + * Uses Electron's desktopCapturer to capture screens and windows. + */ +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import { ipcRenderer } from 'electron'; +import type { ScreenshotSource, ScreenshotCaptureOptions } from '../../shared/types/screenshot'; + +// Re-export types for convenience +export type { ScreenshotSource, ScreenshotCaptureOptions }; + +export interface ScreenshotAPI { + getSources: () => Promise<{ + success: boolean; + data?: ScreenshotSource[]; + error?: string; + }>; + capture: (options: ScreenshotCaptureOptions) => Promise<{ + success: boolean; + data?: string; // base64 encoded PNG + error?: string; + }>; +} + +export const createScreenshotAPI = (): ScreenshotAPI => ({ + getSources: () => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_GET_SOURCES), + capture: (options) => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_CAPTURE, options) +}); diff --git a/apps/frontend/src/renderer/components/ScreenshotCapture.tsx b/apps/frontend/src/renderer/components/ScreenshotCapture.tsx new file mode 100644 index 00000000..a83c6889 --- /dev/null +++ b/apps/frontend/src/renderer/components/ScreenshotCapture.tsx @@ -0,0 +1,298 @@ +/** + * ScreenshotCapture - Modal for capturing screenshots + * + * Displays available screens and windows in a grid, allowing users to + * select a source and capture a screenshot. + * + * Features: + * - Grid layout with thumbnail previews + * - Visual selection with hover effects and checkmarks + * - High-resolution capture support (handles retina displays) + * - Loading states and error handling + * - Refresh button to reload available sources + */ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, RefreshCw, Monitor, Frame, AlertCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; +import { Button } from './ui/button'; +import type { ScreenshotSource } from '../../shared/types/screenshot'; + +interface ScreenshotCaptureProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCapture: (imageData: string) => void; // base64 encoded PNG +} + +export function ScreenshotCapture({ open, onOpenChange, onCapture }: ScreenshotCaptureProps) { + const { t } = useTranslation(['tasks', 'common']); + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isCapturing, setIsCapturing] = useState(false); + const [error, setError] = useState(null); + + /** + * Fetch available screenshot sources + */ + const fetchSources = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const result = await window.electronAPI.getSources(); + if (result.success && result.data) { + setSources(result.data); + setSelectedSource(null); + } else { + setError(result.error || t('tasks:screenshot.errors.getSources')); + } + } catch (err) { + console.error('Failed to fetch screenshot sources:', err); + setError(err instanceof Error ? err.message : t('tasks:screenshot.errors.fetchSources')); + } finally { + setIsLoading(false); + } + }, []); + + // Fetch sources when dialog opens + useEffect(() => { + if (open) { + fetchSources(); + } + }, [open, fetchSources]); + + /** + * Handle capture button click + */ + const handleCapture = async () => { + if (!selectedSource) return; + + setIsCapturing(true); + setError(null); + try { + const result = await window.electronAPI.capture({ sourceId: selectedSource }); + if (result.success && result.data) { + onCapture(result.data); + onOpenChange(false); + setSelectedSource(null); + } else { + setError(result.error || t('tasks:screenshot.errors.capture')); + } + } catch (err) { + console.error('Failed to capture screenshot:', err); + setError(err instanceof Error ? err.message : t('tasks:screenshot.errors.captureFailed')); + } finally { + setIsCapturing(false); + } + }; + + /** + * Determine if a source is a screen or window based on name + */ + const isScreenSource = (source: ScreenshotSource): boolean => { + return source.name.toLowerCase().includes('screen') || + source.name.toLowerCase().includes('display') || + source.name.match(/^\d+:/) !== null; + }; + + return ( + + + + {t('tasks:screenshot.title')} + + {t('tasks:screenshot.description')} + + + +
+ {/* Error State */} + {error && ( +
+ +
+

{error}

+
+ +
+ )} + + {/* Loading State */} + {isLoading && sources.length === 0 && ( +
+ +
+ )} + + {/* Sources Grid */} + {!isLoading && sources.length > 0 && ( +
+ {sources.map((source) => { + const isSelected = selectedSource === source.id; + const isScreen = isScreenSource(source); + + return ( + + ); + })} +
+ )} + + {/* Empty State */} + {!isLoading && sources.length === 0 && !error && ( +
+ +

+ {t('tasks:screenshot.noSources')} +

+ +
+ )} +
+ + {/* Footer Actions */} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx index a5127f29..7fde91bc 100644 --- a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx +++ b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx @@ -6,20 +6,24 @@ * - Title (optional) * - Agent profile selector * - Classification fields (collapsible) - * - Image thumbnails + * - Reference Images section (collapsible, with screenshot capture) * - Review requirement checkbox */ -import { useRef, type ReactNode } from 'react'; +import { useRef, useState, useEffect, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown, ChevronUp, Image as ImageIcon, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, Image as ImageIcon, X, Camera } from 'lucide-react'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Textarea } from '../ui/textarea'; import { Checkbox } from '../ui/checkbox'; +import { Button } from '../ui/button'; import { AgentProfileSelector } from '../AgentProfileSelector'; import { ClassificationFields } from './ClassificationFields'; import { useImageUpload, type FileReferenceData } from './useImageUpload'; +import { createThumbnail } from '../ImageUpload'; +import { ScreenshotCapture } from '../ScreenshotCapture'; import { cn } from '../../lib/utils'; +import { MAX_IMAGES_PER_TASK } from '../../../shared/constants'; import type { TaskCategory, TaskPriority, @@ -137,6 +141,20 @@ export function TaskFormFields({ const descriptionRef = externalDescriptionRef || internalDescriptionRef; const prefix = idPrefix ? `${idPrefix}-` : ''; + // Reference Images section state + const [showReferenceImages, setShowReferenceImages] = useState(false); + const [screenshotModalOpen, setScreenshotModalOpen] = useState(false); + + // Auto-expand reference images section when images are added via paste/drop/capture + const prevImagesLengthRef = useRef(images.length); + useEffect(() => { + if (images.length > 0 && images.length > prevImagesLengthRef.current) { + // Images were added, expand the section + setShowReferenceImages(true); + } + prevImagesLengthRef.current = images.length; + }, [images.length]); + // Use the shared image upload hook with translated error messages const { isDragOver, @@ -160,193 +178,293 @@ export function TaskFormFields({ onFileReferenceDrop }); - return ( -
- {/* Description (Primary - Required) */} -
- -
- {/* Optional overlay (e.g., @ mention highlighting) */} - {descriptionOverlay} -