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:
@@ -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');
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user