feat: Add screenshot capture to task creation modal (#1429)

* feat: Add screenshot capture to task creation modal

Adds a built-in screenshot capture feature to the task creation modal,
allowing users to capture screens or windows directly without leaving
the app.

## Changes Made

### 1. Screenshot Capture Modal (ScreenshotCapture.tsx)
- New modal component that displays all available screenshot sources
- Grid layout showing thumbnail previews of each source
- Visual selection with hover effects and checkmarks
- High-resolution capture support (handles retina displays)
- Loading states and error handling

### 2. Electron IPC Layer
- **IPC Handlers** (screenshot-handlers.ts): Uses Electron's desktopCapturer API
  - SCREENSHOT_GET_SOURCES: Returns list of available screens/windows
  - SCREENSHOT_CAPTURE: Captures full-resolution screenshot from source
- **Preload API** (screenshot-api.ts): Exposes screenshot functionality to renderer
- **Constants** (ipc.ts): Added new IPC channel definitions

### 3. Task Creation Modal Integration (TaskCreationWizard.tsx)
- Added collapsible "Reference Images (optional)" section
- "Capture" button in Reference Images section opens screenshot modal
- Auto-expands section when images are added via paste/drop/capture
- Uses ImageUpload component for consistent UI
- Shows image count badge when images are present
- Automatically generates timestamped filenames

## User Flow

1. Open task creation modal
2. Click "Reference Images (optional)" to expand section
3. Click "Capture" button
4. Screenshot modal opens showing all available screens/windows
5. Select desired screen or window from grid
6. Click "Capture" to add screenshot to task

## Technical Details

- **Electron API**: Uses desktopCapturer.getSources() for screenshot capture
- **Image Processing**: Converts to base64, creates thumbnails, handles MIME types
- **Storage**: Screenshots stored as ImageAttachment objects in task metadata
- **File Naming**: Auto-generates unique timestamped filenames
- **Resolution**: Captures at 2x native resolution for retina display support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address PR review issues for screenshot capture feature

Fixes:
- Add MAX_IMAGES_PER_TASK check in handleScreenshotCapture to prevent limit bypass
- Use createThumbnail instead of full-resolution screenshot as thumbnail
- Create shared types file for ScreenshotSource and ScreenshotCaptureOptions
- Use i18n translation keys for error messages instead of hardcoded English
- Fix French translation from "Capturer une capture d'écran" to "Prendre une capture d'écran"
- Add input validation for sourceId parameter in IPC handler

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
JoshuaRileyDev
2026-01-22 13:14:48 +00:00
committed by GitHub
parent 33acc1430f
commit 1a2a1b1fc9
12 changed files with 817 additions and 186 deletions
+6 -1
View File
@@ -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
};
@@ -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');
}
+8 -3
View File
@@ -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
};
@@ -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)
});
@@ -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<ScreenshotSource[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('tasks:screenshot.title')}</DialogTitle>
<DialogDescription>
{t('tasks:screenshot.description')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{/* Error State */}
{error && (
<div className="flex items-center gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg mb-4">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-destructive">{error}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchSources}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
)}
{/* Loading State */}
{isLoading && sources.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Sources Grid */}
{!isLoading && sources.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-1">
{sources.map((source) => {
const isSelected = selectedSource === source.id;
const isScreen = isScreenSource(source);
return (
<button
key={source.id}
type="button"
onClick={() => setSelectedSource(source.id)}
className={`
relative group rounded-lg border-2 overflow-hidden
transition-all duration-200
${isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-primary/50 hover:ring-2 hover:ring-primary/10'
}
`}
>
{/* Thumbnail */}
<div className="aspect-video bg-muted relative">
{source.thumbnail ? (
<img
src={source.thumbnail}
alt={source.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{isScreen ? (
<Monitor className="h-12 w-12 text-muted-foreground" />
) : (
<Frame className="h-12 w-12 text-muted-foreground" />
)}
</div>
)}
{/* Selection Indicator */}
{isSelected && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-primary flex items-center justify-center">
<svg
className="w-6 h-6 text-primary-foreground"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
{/* Type Icon */}
<div className="absolute top-2 left-2">
<div className={`
p-1.5 rounded-md
${isSelected
? 'bg-primary text-primary-foreground'
: 'bg-background/80 text-foreground backdrop-blur-sm'
}
`}>
{isScreen ? (
<Monitor className="h-4 w-4" />
) : (
<Frame className="h-4 w-4" />
)}
</div>
</div>
</div>
{/* Source Name */}
<div className="p-2 bg-background/95 backdrop-blur-sm">
<p className="text-sm font-medium truncate text-foreground">
{source.name}
</p>
</div>
</button>
);
})}
</div>
)}
{/* Empty State */}
{!isLoading && sources.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground max-w-sm">
{t('tasks:screenshot.noSources')}
</p>
<Button
variant="outline"
size="sm"
onClick={fetchSources}
className="mt-4"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{t('common:buttons.retry')}
</Button>
</div>
)}
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCapturing}
>
{t('common:buttons.cancel')}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={fetchSources}
disabled={isLoading || isCapturing}
title={t('common:buttons.refresh')}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
onClick={handleCapture}
disabled={!selectedSource || isCapturing}
>
{isCapturing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('tasks:screenshot.capturing')}
</>
) : (
t('tasks:screenshot.capture')
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="space-y-6">
{/* Description (Primary - Required) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
{t('tasks:form.description')} <span className="text-destructive">*</span>
</Label>
<div className="relative">
{/* Optional overlay (e.g., @ mention highlighting) */}
{descriptionOverlay}
<Textarea
ref={descriptionRef}
id={`${prefix}description`}
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
rows={6}
disabled={disabled}
aria-required="true"
aria-describedby={`${prefix}description-help`}
className={cn(
'resize-y min-h-[150px] max-h-[400px] relative',
descriptionOverlay && 'bg-transparent',
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
)}
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
/>
</div>
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</p>
/**
* Handle screenshot capture from modal
*
* Validates the max images limit and creates a thumbnail for the screenshot.
*/
const handleScreenshotCapture = async (imageData: string) => {
// Check max images limit
if (images.length >= MAX_IMAGES_PER_TASK) {
onError?.(t('tasks:form.errors.maxImagesReached'));
return;
}
{/* Image Thumbnails - displayed inline below description */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
style={{ width: '72px', height: '72px' }}
title={image.filename}
>
{image.thumbnail ? (
<img
src={image.thumbnail}
alt={image.filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</div>
)}
{/* Remove button */}
{!disabled && (
<button
type="button"
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
// Calculate size from base64 string (approximate)
const base64Length = imageData.length;
const sizeInBytes = Math.round(base64Length * 0.75); // Base64 is ~33% larger than binary
// Create thumbnail from full resolution screenshot
const thumbnail = await createThumbnail(imageData);
const newImage: ImageAttachment = {
id: crypto.randomUUID(),
filename: `screenshot-${Date.now()}.png`,
data: imageData,
thumbnail,
mimeType: 'image/png',
size: sizeInBytes
};
onImagesChange([...images, newImage]);
};
return (
<>
<ScreenshotCapture
open={screenshotModalOpen}
onOpenChange={setScreenshotModalOpen}
onCapture={handleScreenshotCapture}
/>
<div className="space-y-6">
{/* Description (Primary - Required) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
{t('tasks:form.description')} <span className="text-destructive">*</span>
</Label>
<div className="relative">
{/* Optional overlay (e.g., @ mention highlighting) */}
{descriptionOverlay}
<Textarea
ref={descriptionRef}
id={`${prefix}description`}
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
rows={6}
disabled={disabled}
aria-required="true"
aria-describedby={`${prefix}description-help`}
className={cn(
'resize-y min-h-[150px] max-h-[400px] relative',
descriptionOverlay && 'bg-transparent',
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
)}
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
/>
</div>
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</p>
{/* Optional children (e.g., @ mention autocomplete) */}
{children}
</div>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
{t('tasks:form.imageAddedSuccess')}
</div>
)}
{/* Optional children (e.g., @ mention autocomplete) */}
{children}
</div>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
{t('tasks:form.imageAddedSuccess')}
</div>
)}
{/* Title (Optional) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
</Label>
<Input
id={`${prefix}title`}
placeholder={t('tasks:form.titlePlaceholder')}
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{t('tasks:form.titleHelpText')}
</p>
</div>
{/* Agent Profile Selection */}
<AgentProfileSelector
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={onProfileChange}
onModelChange={onModelChange}
onThinkingLevelChange={onThinkingLevelChange}
onPhaseModelsChange={onPhaseModelsChange}
onPhaseThinkingChange={onPhaseThinkingChange}
disabled={disabled}
/>
{/* Classification Toggle */}
<button
type="button"
onClick={() => onShowClassificationChange(!showClassification)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={disabled}
aria-expanded={showClassification}
aria-controls={`${prefix}classification-section`}
>
<span>{t('tasks:form.classificationOptional')}</span>
{showClassification ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Classification Fields */}
{showClassification && (
<div id={`${prefix}classification-section`}>
<ClassificationFields
category={category}
priority={priority}
complexity={complexity}
impact={impact}
onCategoryChange={onCategoryChange}
onPriorityChange={onPriorityChange}
onComplexityChange={onComplexityChange}
onImpactChange={onImpactChange}
disabled={disabled}
idPrefix={idPrefix}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id={`${prefix}require-review`}
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor={`${prefix}require-review`}
className="text-sm font-medium text-foreground cursor-pointer"
>
{t('tasks:form.requireReviewLabel')}
{/* Title (Optional) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
</Label>
<Input
id={`${prefix}title`}
placeholder={t('tasks:form.titlePlaceholder')}
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{t('tasks:form.requireReviewDescription')}
{t('tasks:form.titleHelpText')}
</p>
</div>
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
{/* Agent Profile Selection */}
<AgentProfileSelector
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={onProfileChange}
onModelChange={onModelChange}
onThinkingLevelChange={onThinkingLevelChange}
onPhaseModelsChange={onPhaseModelsChange}
onPhaseThinkingChange={onPhaseThinkingChange}
disabled={disabled}
/>
{/* Reference Images Toggle */}
<button
type="button"
onClick={() => setShowReferenceImages(!showReferenceImages)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={disabled}
aria-expanded={showReferenceImages}
aria-controls={`${prefix}reference-images-section`}
>
<span className="flex items-center gap-2">
{t('tasks:referenceImages.title')}
{images.length > 0 && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{images.length}
</span>
)}
</span>
{showReferenceImages ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Reference Images Section */}
{showReferenceImages && (
<div id={`${prefix}reference-images-section`} className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
<p className="text-xs text-muted-foreground">
{t('tasks:referenceImages.description')}
</p>
{/* Capture Button */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setScreenshotModalOpen(true)}
disabled={disabled}
className="gap-2"
>
<Camera className="h-4 w-4" />
{t('tasks:screenshot.capture')}
</Button>
<span className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</span>
</div>
{/* Image Thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
style={{ width: '72px', height: '72px' }}
title={image.filename}
>
{image.thumbnail ? (
<img
src={image.thumbnail}
alt={image.filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
<ImageIcon className="h-6 w-6 text-muted-foreground" />
</div>
)}
{/* Remove button */}
{!disabled && (
<button
type="button"
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
{images.length === 0 && (
<div className="flex items-center justify-center py-6 border-2 border-dashed border-border rounded-md">
<p className="text-sm text-muted-foreground">
{t('tasks:feedback.dragDropHint')}
</p>
</div>
)}
</div>
)}
{/* Classification Toggle */}
<button
type="button"
onClick={() => onShowClassificationChange(!showClassification)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={disabled}
aria-expanded={showClassification}
aria-controls={`${prefix}classification-section`}
>
<span>{t('tasks:form.classificationOptional')}</span>
{showClassification ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Classification Fields */}
{showClassification && (
<div id={`${prefix}classification-section`}>
<ClassificationFields
category={category}
priority={priority}
complexity={complexity}
impact={impact}
onCategoryChange={onCategoryChange}
onPriorityChange={onPriorityChange}
onComplexityChange={onComplexityChange}
onImpactChange={onImpactChange}
disabled={disabled}
idPrefix={idPrefix}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id={`${prefix}require-review`}
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor={`${prefix}require-review`}
className="text-sm font-medium text-foreground cursor-pointer"
>
{t('tasks:form.requireReviewLabel')}
</Label>
<p className="text-xs text-muted-foreground">
{t('tasks:form.requireReviewDescription')}
</p>
</div>
</div>
)}
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
</>
);
}
@@ -318,6 +318,16 @@ const browserMockAPI: ElectronAPI = {
}
}),
// Screenshot capture operations
getSources: async () => ({
success: true,
data: []
}),
capture: async (_options: { sourceId: string }) => ({
success: false,
error: 'Screenshot capture not available in browser mode'
}),
// Debug Operations
getDebugInfo: async () => ({
systemInfo: {
+5 -1
View File
@@ -532,5 +532,9 @@ export const IPC_CHANNELS = {
// Sentry error reporting
SENTRY_STATE_CHANGED: 'sentry:state-changed', // Notify main process when setting changes
GET_SENTRY_DSN: 'sentry:get-dsn', // Get DSN from main process (env var)
GET_SENTRY_CONFIG: 'sentry:get-config' // Get full Sentry config (DSN + sample rates)
GET_SENTRY_CONFIG: 'sentry:get-config', // Get full Sentry config (DSN + sample rates)
// Screenshot capture
SCREENSHOT_GET_SOURCES: 'screenshot:getSources', // Get available screens/windows
SCREENSHOT_CAPTURE: 'screenshot:capture' // Capture screenshot from source
} as const;
@@ -284,5 +284,22 @@
"noTasksToSelect": "No tasks available to select",
"confirmBulkAction": "Confirm bulk action for {{count}} tasks",
"processingTasks": "Processing selected tasks..."
},
"screenshot": {
"title": "Capture Screenshot",
"description": "Select a screen or window to capture as a reference image",
"capture": "Capture",
"capturing": "Capturing...",
"noSources": "No screens or windows found",
"errors": {
"getSources": "Failed to get screenshot sources",
"fetchSources": "Failed to fetch screenshot sources",
"capture": "Failed to capture screenshot",
"captureFailed": "Failed to capture screenshot"
}
},
"referenceImages": {
"title": "Reference Images (optional)",
"description": "Add visual references like screenshots or designs to help the AI understand your requirements."
}
}
@@ -283,5 +283,22 @@
"noTasksToSelect": "Aucune tâche disponible à sélectionner",
"confirmBulkAction": "Confirmer l'action groupée pour {{count}} tâches",
"processingTasks": "Traitement des tâches sélectionnées..."
},
"screenshot": {
"title": "Prendre une capture d'écran",
"description": "Sélectionnez un écran ou une fenêtre à capturer comme image de référence",
"capture": "Capturer",
"capturing": "Capture...",
"noSources": "Aucun écran ou fenêtre trouvé",
"errors": {
"getSources": "Échec de l'obtention des sources de capture d'écran",
"fetchSources": "Échec de la récupération des sources de capture d'écran",
"capture": "Échec de la capture d'écran",
"captureFailed": "Échec de la capture d'écran"
}
},
"referenceImages": {
"title": "Images de référence (facultatif)",
"description": "Ajoutez des références visuelles comme des captures d'écran ou des conceptions pour aider l'IA à comprendre vos exigences."
}
}
+8
View File
@@ -835,6 +835,14 @@ export interface ElectronAPI {
// MCP Server health check operations
checkMcpHealth: (server: CustomMcpServer) => Promise<IPCResult<McpHealthCheckResult>>;
testMcpConnection: (server: CustomMcpServer) => Promise<IPCResult<McpTestConnectionResult>>;
// Screenshot capture operations
getSources: () => Promise<IPCResult<Array<{
id: string;
name: string;
thumbnail: string;
}>>>;
capture: (options: { sourceId: string }) => Promise<IPCResult<string>>;
}
declare global {
@@ -0,0 +1,25 @@
/**
* Screenshot capture types
*
* Shared types for screenshot functionality across main, preload, and renderer processes.
*/
/**
* Represents a screenshot source (screen or window) available for capture
*/
export interface ScreenshotSource {
/** Unique identifier for the source */
id: string;
/** Display name of the source (e.g., "Screen 1", "Chrome") */
name: string;
/** Base64 encoded PNG thumbnail preview */
thumbnail: string;
}
/**
* Options for capturing a screenshot
*/
export interface ScreenshotCaptureOptions {
/** The ID of the source to capture */
sourceId: string;
}