mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
fix conflicts
This commit is contained in:
@@ -16,9 +16,12 @@ import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
import {
|
||||
fileToBase64,
|
||||
validateImageFile,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
} from '@/lib/image-utils';
|
||||
|
||||
interface BoardBackgroundModalProps {
|
||||
open: boolean;
|
||||
@@ -71,21 +74,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
}
|
||||
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!currentProject) {
|
||||
@@ -93,16 +81,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
toast.error('Unsupported file type. Please use JPG, PNG, GIF, or WebP.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > DEFAULT_MAX_FILE_SIZE) {
|
||||
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
|
||||
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
// Validate file
|
||||
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
toast.error(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export function Sidebar() {
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
const creatingSpecProjectPath = specCreatingForProject;
|
||||
|
||||
// Auto-collapse sidebar on small screens
|
||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
|
||||
// Running agents count
|
||||
|
||||
@@ -32,4 +32,17 @@ export function useSidebarAutoCollapse({
|
||||
mediaQuery.addEventListener('change', handleResize);
|
||||
return () => mediaQuery.removeEventListener('change', handleResize);
|
||||
}, [sidebarOpen, toggleSidebar]);
|
||||
|
||||
// Update Electron window minWidth when sidebar state changes
|
||||
// This ensures the window can't be resized smaller than what the kanban board needs
|
||||
useEffect(() => {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
|
||||
}
|
||||
).electronAPI;
|
||||
if (electronAPI?.updateMinWidth) {
|
||||
electronAPI.updateMinWidth(sidebarOpen);
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Loader2 } from 'lucide-react';
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
fileToBase64,
|
||||
fileToText,
|
||||
isTextFile,
|
||||
isImageFile,
|
||||
validateTextFile,
|
||||
getTextFileMimeType,
|
||||
generateFileId,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_TEXT_EXTENSIONS,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_TEXT_FILE_SIZE,
|
||||
formatFileSize,
|
||||
} from '@/lib/image-utils';
|
||||
|
||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||
export type ImagePreviewMap = Map<string, string>;
|
||||
|
||||
// Re-export for convenience
|
||||
export type { FeatureImagePath, FeatureTextFilePath };
|
||||
|
||||
interface DescriptionImageDropZoneProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
images: FeatureImagePath[];
|
||||
onImagesChange: (images: FeatureImagePath[]) => void;
|
||||
textFiles?: FeatureTextFilePath[];
|
||||
onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -25,14 +45,13 @@ interface DescriptionImageDropZoneProps {
|
||||
error?: boolean; // Show error state with red border
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function DescriptionImageDropZone({
|
||||
value,
|
||||
onChange,
|
||||
images,
|
||||
onImagesChange,
|
||||
textFiles = [],
|
||||
onTextFilesChange,
|
||||
placeholder = 'Describe the feature...',
|
||||
className,
|
||||
disabled = false,
|
||||
@@ -81,21 +100,6 @@ export function DescriptionImageDropZone({
|
||||
[currentProject?.path]
|
||||
);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const saveImageToTemp = useCallback(
|
||||
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
|
||||
try {
|
||||
@@ -129,54 +133,89 @@ export function DescriptionImageDropZone({
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: FeatureImagePath[] = [];
|
||||
const newTextFiles: FeatureTextFilePath[] = [];
|
||||
const newPreviews = new Map(previewImages);
|
||||
const errors: string[] = [];
|
||||
|
||||
// Calculate total current files
|
||||
const currentTotalFiles = images.length + textFiles.length;
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + images.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tempPath = await saveImageToTemp(base64, file.name, file.type);
|
||||
|
||||
if (tempPath) {
|
||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const imagePathRef: FeatureImagePath = {
|
||||
id: imageId,
|
||||
path: tempPath,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
};
|
||||
newImages.push(imagePathRef);
|
||||
// Store preview for display
|
||||
newPreviews.set(imageId, base64);
|
||||
} else {
|
||||
errors.push(`${file.name}: Failed to save image.`);
|
||||
// Check if it's a text file
|
||||
if (isTextFile(file)) {
|
||||
const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
|
||||
if (totalFiles >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fileToText(file);
|
||||
const sanitizedName = sanitizeFilename(file.name);
|
||||
const textFilePath: FeatureTextFilePath = {
|
||||
id: generateFileId(),
|
||||
path: '', // Text files don't need to be saved to disk
|
||||
filename: sanitizedName,
|
||||
mimeType: getTextFileMimeType(file.name),
|
||||
content,
|
||||
};
|
||||
newTextFiles.push(textFilePath);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to read text file.`);
|
||||
}
|
||||
}
|
||||
// Check if it's an image file
|
||||
else if (isImageFile(file)) {
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
|
||||
if (totalFiles >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const sanitizedName = sanitizeFilename(file.name);
|
||||
const tempPath = await saveImageToTemp(base64, sanitizedName, file.type);
|
||||
|
||||
if (tempPath) {
|
||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const imagePathRef: FeatureImagePath = {
|
||||
id: imageId,
|
||||
path: tempPath,
|
||||
filename: sanitizedName,
|
||||
mimeType: file.type,
|
||||
};
|
||||
newImages.push(imagePathRef);
|
||||
// Store preview for display
|
||||
newPreviews.set(imageId, base64);
|
||||
} else {
|
||||
errors.push(`${file.name}: Failed to save image.`);
|
||||
}
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
console.warn('File upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
@@ -184,15 +223,21 @@ export function DescriptionImageDropZone({
|
||||
setPreviewImages(newPreviews);
|
||||
}
|
||||
|
||||
if (newTextFiles.length > 0 && onTextFilesChange) {
|
||||
onTextFilesChange([...textFiles, ...newTextFiles]);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
isProcessing,
|
||||
images,
|
||||
textFiles,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
onImagesChange,
|
||||
onTextFilesChange,
|
||||
previewImages,
|
||||
saveImageToTemp,
|
||||
]
|
||||
@@ -263,6 +308,15 @@ export function DescriptionImageDropZone({
|
||||
[images, onImagesChange]
|
||||
);
|
||||
|
||||
const removeTextFile = useCallback(
|
||||
(fileId: string) => {
|
||||
if (onTextFilesChange) {
|
||||
onTextFilesChange(textFiles.filter((file) => file.id !== fileId));
|
||||
}
|
||||
},
|
||||
[textFiles, onTextFilesChange]
|
||||
);
|
||||
|
||||
// Handle paste events to detect and process images from clipboard
|
||||
// Works across all OS (Windows, Linux, macOS)
|
||||
const handlePaste = useCallback(
|
||||
@@ -314,11 +368,11 @@ export function DescriptionImageDropZone({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
data-testid="description-image-input"
|
||||
data-testid="description-file-input"
|
||||
/>
|
||||
|
||||
{/* Drop zone wrapper */}
|
||||
@@ -338,7 +392,7 @@ export function DescriptionImageDropZone({
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-blue-400">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-sm font-medium">Drop images here</span>
|
||||
<span className="text-sm font-medium">Drop files here</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -359,7 +413,7 @@ export function DescriptionImageDropZone({
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Paste, drag and drop images, or{' '}
|
||||
Paste, drag and drop files, or{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseClick}
|
||||
@@ -368,29 +422,33 @@ export function DescriptionImageDropZone({
|
||||
>
|
||||
browse
|
||||
</button>{' '}
|
||||
to attach context images
|
||||
to attach context (images, .txt, .md)
|
||||
</p>
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Saving images...</span>
|
||||
<span>Processing files...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
||||
{/* File previews (images and text files) */}
|
||||
{(images.length > 0 || textFiles.length > 0) && (
|
||||
<div className="mt-3 space-y-2" data-testid="description-file-previews">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{images.length} image{images.length > 1 ? 's' : ''} attached
|
||||
{images.length + textFiles.length} file
|
||||
{images.length + textFiles.length > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onImagesChange([]);
|
||||
setPreviewImages(new Map());
|
||||
if (onTextFilesChange) {
|
||||
onTextFilesChange([]);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
@@ -399,6 +457,7 @@ export function DescriptionImageDropZone({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image previews */}
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
@@ -445,6 +504,38 @@ export function DescriptionImageDropZone({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Text file previews */}
|
||||
{textFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
|
||||
data-testid={`description-text-file-preview-${file.id}`}
|
||||
>
|
||||
{/* Text file icon */}
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
|
||||
<FileText className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTextFile(file.id);
|
||||
}}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-testid={`remove-description-text-file-${file.id}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{/* Filename and size tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">{file.filename}</p>
|
||||
<p className="text-[9px] text-white/70">{formatFileSize(file.content.length)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
validateImageFile,
|
||||
} from '@/lib/image-utils';
|
||||
|
||||
export interface FeatureImage {
|
||||
id: string;
|
||||
@@ -19,13 +27,10 @@ interface FeatureImageUploadProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function FeatureImageUpload({
|
||||
images,
|
||||
onImagesChange,
|
||||
maxFiles = 5,
|
||||
maxFiles = DEFAULT_MAX_FILES,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
disabled = false,
|
||||
@@ -34,21 +39,6 @@ export function FeatureImageUpload({
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
@@ -58,16 +48,10 @@ export function FeatureImageUpload({
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
// Validate file
|
||||
const validation = validateImageFile(file, maxFileSize);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -80,7 +64,7 @@ export function FeatureImageUpload({
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: FeatureImage = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: generateImageId(),
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
|
||||
@@ -2,6 +2,15 @@ import React, { useState, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
formatFileSize,
|
||||
validateImageFile,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
} from '@/lib/image-utils';
|
||||
|
||||
interface ImageDropZoneProps {
|
||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||
@@ -13,12 +22,9 @@ interface ImageDropZoneProps {
|
||||
images?: ImageAttachment[]; // Optional controlled images prop
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function ImageDropZone({
|
||||
onImagesSelected,
|
||||
maxFiles = 5,
|
||||
maxFiles = DEFAULT_MAX_FILES,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
children,
|
||||
@@ -53,16 +59,10 @@ export function ImageDropZone({
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
// Validate file
|
||||
const validation = validateImageFile(file, maxFileSize);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ImageDropZone({
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: generateImageId(),
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
@@ -89,7 +89,6 @@ export function ImageDropZone({
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
// You could show these errors to the user via a toast or notification
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
@@ -282,26 +281,3 @@ export function ImageDropZone({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -16,12 +16,27 @@ import {
|
||||
X,
|
||||
ImageIcon,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||
import { SessionManager } from '@/components/session-manager';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
generateFileId,
|
||||
validateImageFile,
|
||||
validateTextFile,
|
||||
isTextFile,
|
||||
isImageFile,
|
||||
fileToText,
|
||||
getTextFileMimeType,
|
||||
formatFileSize,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
} from '@/lib/image-utils';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
@@ -40,6 +55,7 @@ export function AgentView() {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [input, setInput] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
|
||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
@@ -116,17 +132,23 @@ export function AgentView() {
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
|
||||
if (
|
||||
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
|
||||
isProcessing
|
||||
)
|
||||
return;
|
||||
|
||||
const messageContent = input;
|
||||
const messageImages = selectedImages;
|
||||
const messageTextFiles = selectedTextFiles;
|
||||
|
||||
setInput('');
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
setShowImageDropZone(false);
|
||||
|
||||
await sendMessage(messageContent, messageImages);
|
||||
}, [input, selectedImages, isProcessing, sendMessage]);
|
||||
await sendMessage(messageContent, messageImages, messageTextFiles);
|
||||
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
|
||||
|
||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||
setSelectedImages(images);
|
||||
@@ -136,84 +158,99 @@ export function AgentView() {
|
||||
setShowImageDropZone(!showImageDropZone);
|
||||
}, [showImageDropZone]);
|
||||
|
||||
// Helper function to convert file to base64
|
||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Process dropped files
|
||||
// Process dropped files (images and text files)
|
||||
const processDroppedFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILES = 5;
|
||||
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const newTextFiles: TextFileAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
// Check if it's a text file
|
||||
if (isTextFile(file)) {
|
||||
const validation = validateTextFile(file);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
// Check if we've reached max files
|
||||
const totalFiles =
|
||||
newImages.length +
|
||||
selectedImages.length +
|
||||
newTextFiles.length +
|
||||
selectedTextFiles.length;
|
||||
if (totalFiles >= DEFAULT_MAX_FILES) {
|
||||
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + selectedImages.length >= MAX_FILES) {
|
||||
errors.push(`Maximum ${MAX_FILES} images allowed.`);
|
||||
break;
|
||||
try {
|
||||
const content = await fileToText(file);
|
||||
const textFileAttachment: TextFileAttachment = {
|
||||
id: generateFileId(),
|
||||
content,
|
||||
mimeType: getTextFileMimeType(file.name),
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newTextFiles.push(textFileAttachment);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to read text file.`);
|
||||
}
|
||||
}
|
||||
// Check if it's an image file
|
||||
else if (isImageFile(file)) {
|
||||
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
// Check if we've reached max files
|
||||
const totalFiles =
|
||||
newImages.length +
|
||||
selectedImages.length +
|
||||
newTextFiles.length +
|
||||
selectedTextFiles.length;
|
||||
if (totalFiles >= DEFAULT_MAX_FILES) {
|
||||
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: generateImageId(),
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
console.warn('File upload errors:', errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
|
||||
if (newTextFiles.length > 0) {
|
||||
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
|
||||
}
|
||||
},
|
||||
[isProcessing, selectedImages, fileToBase64]
|
||||
[isProcessing, selectedImages, selectedTextFiles]
|
||||
);
|
||||
|
||||
// Remove individual image
|
||||
@@ -221,6 +258,11 @@ export function AgentView() {
|
||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
// Remove individual text file
|
||||
const removeTextFile = useCallback((fileId: string) => {
|
||||
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
|
||||
}, []);
|
||||
|
||||
// Drag and drop handlers for the input area
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -720,16 +762,19 @@ export function AgentView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||
{selectedImages.length > 0 && !showImageDropZone && (
|
||||
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? 's' : ''} attached
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelectedImages([])}
|
||||
onClick={() => {
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
@@ -737,6 +782,7 @@ export function AgentView() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image attachments */}
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
@@ -773,6 +819,35 @@ export function AgentView() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Text file attachments */}
|
||||
{selectedTextFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* File icon */}
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{file.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => removeTextFile(file.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -792,7 +867,7 @@ export function AgentView() {
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
|
||||
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -803,14 +878,15 @@ export function AgentView() {
|
||||
className={cn(
|
||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
selectedImages.length > 0 && 'border-primary/30',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
{selectedImages.length > 0 && !isDragOver && (
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? 's' : ''}
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
@@ -821,7 +897,7 @@ export function AgentView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Attachment Button */}
|
||||
{/* File Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -830,9 +906,10 @@ export function AgentView() {
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-xl border-border',
|
||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||
selectedImages.length > 0 && 'border-primary/30 text-primary'
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30 text-primary'
|
||||
)}
|
||||
title="Attach images"
|
||||
title="Attach files (images, .txt, .md)"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -841,7 +918,11 @@ export function AgentView() {
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
|
||||
(!input.trim() &&
|
||||
selectedImages.length === 0 &&
|
||||
selectedTextFiles.length === 0) ||
|
||||
isProcessing ||
|
||||
!isConnected
|
||||
}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
data-testid="send-message"
|
||||
@@ -862,12 +943,3 @@ export function AgentView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'relative flex flex-col h-full rounded-xl transition-all duration-200',
|
||||
'relative flex flex-col h-full rounded-xl',
|
||||
// Only transition ring/shadow for drag-over effect, not width
|
||||
'transition-[box-shadow,ring] duration-200',
|
||||
!width && 'w-72', // Only apply w-72 if no custom width
|
||||
showBorder && 'border border-border/60',
|
||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||
|
||||
@@ -16,6 +16,7 @@ import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ interface AddFeatureDialogProps {
|
||||
description: string;
|
||||
images: FeatureImage[];
|
||||
imagePaths: DescriptionImagePath[];
|
||||
textFilePaths: DescriptionTextFilePath[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
@@ -104,6 +106,7 @@ export function AddFeatureDialog({
|
||||
description: '',
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
textFilePaths: [] as DescriptionTextFilePath[],
|
||||
skipTests: false,
|
||||
model: 'opus' as AgentModel,
|
||||
thinkingLevel: 'none' as ThinkingLevel,
|
||||
@@ -190,6 +193,7 @@ export function AddFeatureDialog({
|
||||
description: newFeature.description,
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
textFilePaths: newFeature.textFilePaths,
|
||||
skipTests: newFeature.skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
@@ -206,6 +210,7 @@ export function AddFeatureDialog({
|
||||
description: '',
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
textFilePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus',
|
||||
priority: 2,
|
||||
@@ -327,6 +332,10 @@ export function AddFeatureDialog({
|
||||
}}
|
||||
images={newFeature.imagePaths}
|
||||
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
||||
textFiles={newFeature.textFilePaths}
|
||||
onTextFilesChange={(textFiles) =>
|
||||
setNewFeature({ ...newFeature, textFilePaths: textFiles })
|
||||
}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={newFeaturePreviewMap}
|
||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
textFilePaths: DescriptionTextFilePath[];
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
@@ -166,6 +168,7 @@ export function EditFeatureDialog({
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
textFilePaths: editingFeature.textFilePaths ?? [],
|
||||
branchName: finalBranchName,
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
@@ -292,6 +295,13 @@ export function EditFeatureDialog({
|
||||
imagePaths: images,
|
||||
})
|
||||
}
|
||||
textFiles={editingFeature.textFilePaths ?? []}
|
||||
onTextFilesChange={(textFiles) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
textFilePaths: textFiles,
|
||||
})
|
||||
}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={editFeaturePreviewMap}
|
||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||
|
||||
@@ -82,17 +82,18 @@ export function KanbanBoard({
|
||||
onArchiveAllVerified,
|
||||
}: KanbanBoardProps) {
|
||||
// Use responsive column widths based on window size
|
||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
||||
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="flex gap-5 h-full py-1 justify-center">
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { Message, StreamEvent } from '@/types/electron';
|
||||
import { useMessageQueue } from './use-message-queue';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { sanitizeFilename } from '@/lib/image-utils';
|
||||
|
||||
interface UseElectronAgentOptions {
|
||||
sessionId: string;
|
||||
@@ -15,12 +16,22 @@ interface UseElectronAgentResult {
|
||||
messages: Message[];
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
|
||||
sendMessage: (
|
||||
content: string,
|
||||
images?: ImageAttachment[],
|
||||
textFiles?: TextFileAttachment[]
|
||||
) => Promise<void>;
|
||||
stopExecution: () => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
error: string | null;
|
||||
// Queue-related state
|
||||
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
|
||||
queuedMessages: {
|
||||
id: string;
|
||||
content: string;
|
||||
images?: ImageAttachment[];
|
||||
textFiles?: TextFileAttachment[];
|
||||
timestamp: Date;
|
||||
}[];
|
||||
isQueueProcessing: boolean;
|
||||
clearMessageQueue: () => void;
|
||||
}
|
||||
@@ -46,7 +57,7 @@ export function useElectronAgent({
|
||||
|
||||
// Send message directly to the agent (bypassing queue)
|
||||
const sendMessageDirectly = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError('API not available');
|
||||
@@ -64,8 +75,20 @@ export function useElectronAgent({
|
||||
console.log('[useElectronAgent] Sending message directly', {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0,
|
||||
hasTextFiles: textFiles && textFiles.length > 0,
|
||||
textFileCount: textFiles?.length || 0,
|
||||
});
|
||||
|
||||
// Build message content with text file context prepended
|
||||
let messageContent = content;
|
||||
if (textFiles && textFiles.length > 0) {
|
||||
const contextParts = textFiles.map((file) => {
|
||||
return `<file name="${file.filename}">\n${file.content}\n</file>`;
|
||||
});
|
||||
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
|
||||
messageContent = contextBlock + content;
|
||||
}
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
@@ -73,7 +96,7 @@ export function useElectronAgent({
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
sanitizeFilename(image.filename),
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
@@ -88,7 +111,7 @@ export function useElectronAgent({
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
messageContent,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
@@ -114,7 +137,11 @@ export function useElectronAgent({
|
||||
const { queuedMessages, isProcessingQueue, addToQueue, clearQueue, processNext } =
|
||||
useMessageQueue({
|
||||
onProcessNext: async (queuedMessage) => {
|
||||
await sendMessageDirectly(queuedMessage.content, queuedMessage.images);
|
||||
await sendMessageDirectly(
|
||||
queuedMessage.content,
|
||||
queuedMessage.images,
|
||||
queuedMessage.textFiles
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -288,7 +315,7 @@ export function useElectronAgent({
|
||||
|
||||
// Send a message to the agent
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
async (content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError('API not available');
|
||||
@@ -307,8 +334,20 @@ export function useElectronAgent({
|
||||
console.log('[useElectronAgent] Sending message', {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0,
|
||||
hasTextFiles: textFiles && textFiles.length > 0,
|
||||
textFileCount: textFiles?.length || 0,
|
||||
});
|
||||
|
||||
// Build message content with text file context prepended
|
||||
let messageContent = content;
|
||||
if (textFiles && textFiles.length > 0) {
|
||||
const contextParts = textFiles.map((file) => {
|
||||
return `<file name="${file.filename}">\n${file.content}\n</file>`;
|
||||
});
|
||||
const contextBlock = `Here are some files for context:\n\n${contextParts.join('\n\n')}\n\n`;
|
||||
messageContent = contextBlock + content;
|
||||
}
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
@@ -316,7 +355,7 @@ export function useElectronAgent({
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
sanitizeFilename(image.filename),
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
@@ -331,7 +370,7 @@ export function useElectronAgent({
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
messageContent,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
images?: ImageAttachment[];
|
||||
textFiles?: TextFileAttachment[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -15,7 +16,11 @@ interface UseMessageQueueOptions {
|
||||
interface UseMessageQueueResult {
|
||||
queuedMessages: QueuedMessage[];
|
||||
isProcessingQueue: boolean;
|
||||
addToQueue: (content: string, images?: ImageAttachment[]) => void;
|
||||
addToQueue: (
|
||||
content: string,
|
||||
images?: ImageAttachment[],
|
||||
textFiles?: TextFileAttachment[]
|
||||
) => void;
|
||||
clearQueue: () => void;
|
||||
removeFromQueue: (messageId: string) => void;
|
||||
processNext: () => Promise<void>;
|
||||
@@ -31,16 +36,20 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
|
||||
const addToQueue = useCallback((content: string, images?: ImageAttachment[]) => {
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
content: content.trim(),
|
||||
images,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const addToQueue = useCallback(
|
||||
(content: string, images?: ImageAttachment[], textFiles?: TextFileAttachment[]) => {
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id: `queued-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
content: content.trim(),
|
||||
images,
|
||||
textFiles,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setQueuedMessages((prev) => [...prev, queuedMessage]);
|
||||
}, []);
|
||||
setQueuedMessages((prev) => [...prev, queuedMessage]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeFromQueue = useCallback((messageId: string) => {
|
||||
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
export interface ResponsiveKanbanConfig {
|
||||
columnWidth: number;
|
||||
@@ -13,16 +14,21 @@ export interface ResponsiveKanbanConfig {
|
||||
*/
|
||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||
columnWidth: 288, // 18rem = 288px (w-72)
|
||||
columnMinWidth: 280, // Minimum column width - increased to ensure usability
|
||||
columnMaxWidth: 360, // Maximum column width to ensure responsive scaling
|
||||
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||
gap: 20, // gap-5 = 20px
|
||||
padding: 32, // px-4 on both sides = 32px
|
||||
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||
};
|
||||
|
||||
// Sidebar transition duration (matches sidebar.tsx)
|
||||
const SIDEBAR_TRANSITION_MS = 300;
|
||||
|
||||
export interface UseResponsiveKanbanResult {
|
||||
columnWidth: number;
|
||||
containerStyle: React.CSSProperties;
|
||||
isCompact: boolean;
|
||||
totalBoardWidth: number;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +36,14 @@ export interface UseResponsiveKanbanResult {
|
||||
* Ensures columns scale intelligently to fill available space without
|
||||
* dead space on the right or content being cut off.
|
||||
*
|
||||
* Features:
|
||||
* - Uses useLayoutEffect to calculate width before paint (prevents bounce)
|
||||
* - Observes actual board container for accurate sizing
|
||||
* - Recalculates after sidebar transitions
|
||||
*
|
||||
* @param columnCount - Number of columns in the Kanban board
|
||||
* @param config - Optional configuration for column sizing
|
||||
* @returns Object with calculated column width and container styles
|
||||
* @returns Object with calculated column width, container styles, and metrics
|
||||
*/
|
||||
export function useResponsiveKanban(
|
||||
columnCount: number = 4,
|
||||
@@ -43,68 +54,129 @@ export function useResponsiveKanban(
|
||||
...config,
|
||||
};
|
||||
|
||||
const calculateColumnWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CONFIG.columnWidth;
|
||||
}
|
||||
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
|
||||
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Get the actual board container width
|
||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||
const containerWidth = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||
const calculateColumnWidth = useCallback(
|
||||
(containerWidth?: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CONFIG.columnWidth;
|
||||
}
|
||||
|
||||
// Get the available width (subtract padding only)
|
||||
const availableWidth = containerWidth - padding;
|
||||
// Get the actual board container width
|
||||
// The flex layout already accounts for sidebar width, so we use the container's actual width
|
||||
let width = containerWidth;
|
||||
if (width === undefined) {
|
||||
const boardContainer = document.querySelector('[data-testid="board-view"]')?.parentElement;
|
||||
width = boardContainer ? boardContainer.clientWidth : window.innerWidth;
|
||||
}
|
||||
|
||||
// Calculate total gap space needed
|
||||
const totalGapWidth = gap * (columnCount - 1);
|
||||
// Get the available width (subtract padding only)
|
||||
const availableWidth = width - padding;
|
||||
|
||||
// Calculate width available for all columns
|
||||
const widthForColumns = availableWidth - totalGapWidth;
|
||||
// Calculate total gap space needed
|
||||
const totalGapWidth = gap * (columnCount - 1);
|
||||
|
||||
// Calculate ideal column width
|
||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||
// Calculate width available for all columns
|
||||
const widthForColumns = availableWidth - totalGapWidth;
|
||||
|
||||
// Clamp to min/max bounds
|
||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||
// Calculate ideal column width
|
||||
let idealWidth = Math.floor(widthForColumns / columnCount);
|
||||
|
||||
return idealWidth;
|
||||
}, [columnCount, columnMinWidth, columnMaxWidth, gap, padding]);
|
||||
// Clamp to min/max bounds
|
||||
idealWidth = Math.max(columnMinWidth, Math.min(columnMaxWidth, idealWidth));
|
||||
|
||||
return idealWidth;
|
||||
},
|
||||
[columnCount, columnMinWidth, columnMaxWidth, gap, padding]
|
||||
);
|
||||
|
||||
const [columnWidth, setColumnWidth] = useState<number>(() => calculateColumnWidth());
|
||||
|
||||
// Use useLayoutEffect to calculate width synchronously before paint
|
||||
// This prevents the "bounce" effect when navigating to the kanban view
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
// Calculate immediately before paint
|
||||
updateWidth();
|
||||
}, [calculateColumnWidth]);
|
||||
|
||||
// Set up ResizeObserver for ongoing resize handling
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleResize = () => {
|
||||
const updateWidth = () => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
};
|
||||
|
||||
// Set initial width
|
||||
handleResize();
|
||||
// Debounced update for smooth resize transitions
|
||||
const scheduleUpdate = () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(updateWidth, 50);
|
||||
};
|
||||
|
||||
// Use ResizeObserver for more precise updates if available
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(document.body);
|
||||
// Use ResizeObserver on the actual board container for precise updates
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
||||
const container = boardView?.parentElement;
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
if (container && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
// Use the observed container's width for calculation
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const containerWidth = entry.contentRect.width;
|
||||
const newWidth = calculateColumnWidth(containerWidth);
|
||||
setColumnWidth(newWidth);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
// Fallback to window resize event
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [calculateColumnWidth]);
|
||||
|
||||
// Re-calculate after sidebar transitions complete
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
const newWidth = calculateColumnWidth();
|
||||
setColumnWidth(newWidth);
|
||||
}, SIDEBAR_TRANSITION_MS + 50); // Wait for transition to complete
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [sidebarOpen, calculateColumnWidth]);
|
||||
|
||||
// Determine if we're in compact mode (columns at minimum width)
|
||||
const isCompact = columnWidth <= columnMinWidth + 10;
|
||||
|
||||
// Container style to center content and prevent overflow
|
||||
// Calculate total board width for container sizing
|
||||
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
|
||||
|
||||
// Container style to center content
|
||||
// Use flex layout with justify-center to naturally center columns
|
||||
// The parent container has px-4 padding which provides equal left/right margins
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: `${gap}px`,
|
||||
@@ -116,5 +188,7 @@ export function useResponsiveKanban(
|
||||
columnWidth,
|
||||
containerStyle,
|
||||
isCompact,
|
||||
totalBoardWidth,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,6 +536,18 @@ export interface ElectronAPI {
|
||||
claude?: {
|
||||
getUsage: () => Promise<ClaudeUsageResponse>;
|
||||
};
|
||||
context?: {
|
||||
describeImage: (imagePath: string) => Promise<{
|
||||
success: boolean;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
describeFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: Window interface is declared in @/types/electron.d.ts
|
||||
|
||||
@@ -1011,6 +1011,25 @@ export class HttpApiClient implements ElectronAPI {
|
||||
claude = {
|
||||
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
|
||||
};
|
||||
|
||||
// Context API
|
||||
context = {
|
||||
describeImage: (
|
||||
imagePath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}> => this.post('/api/context/describe-image', { imagePath }),
|
||||
|
||||
describeFile: (
|
||||
filePath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}> => this.post('/api/context/describe-file', { filePath }),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
236
apps/ui/src/lib/image-utils.ts
Normal file
236
apps/ui/src/lib/image-utils.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Shared utilities for image and file handling across the UI
|
||||
*/
|
||||
|
||||
// Accepted image MIME types
|
||||
export const ACCEPTED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
// Accepted text file MIME types
|
||||
export const ACCEPTED_TEXT_TYPES = ['text/plain', 'text/markdown', 'text/x-markdown'];
|
||||
|
||||
// File extensions for text files (used for validation when MIME type is unreliable)
|
||||
export const ACCEPTED_TEXT_EXTENSIONS = ['.txt', '.md'];
|
||||
|
||||
// Default max file size (10MB)
|
||||
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// Default max text file size (1MB - text files should be smaller)
|
||||
export const DEFAULT_MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024;
|
||||
|
||||
// Default max number of files
|
||||
export const DEFAULT_MAX_FILES = 5;
|
||||
|
||||
/**
|
||||
* Sanitize a filename by replacing spaces and special characters with underscores.
|
||||
* This is important for:
|
||||
* - Mac screenshot filenames that contain Unicode narrow no-break spaces (U+202F)
|
||||
* - Filenames with regular spaces
|
||||
* - Filenames with special characters that may cause path issues
|
||||
*
|
||||
* @param filename - The original filename
|
||||
* @returns A sanitized filename safe for file system operations
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
const name = lastDot > 0 ? filename.substring(0, lastDot) : filename;
|
||||
const ext = lastDot > 0 ? filename.substring(lastDot) : '';
|
||||
|
||||
const sanitized = name
|
||||
.replace(/[\s\u00A0\u202F\u2009\u200A]+/g, '_') // Various space characters (regular, non-breaking, narrow no-break, thin, hair)
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_') // Non-alphanumeric chars
|
||||
.replace(/_+/g, '_') // Collapse multiple underscores
|
||||
.replace(/^_|_$/g, ''); // Trim leading/trailing underscores
|
||||
|
||||
return `${sanitized || 'image'}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File object to a base64 data URL string
|
||||
*
|
||||
* @param file - The file to convert
|
||||
* @returns Promise resolving to a base64 data URL string
|
||||
*/
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base64 data from a data URL (removes the prefix)
|
||||
*
|
||||
* @param dataUrl - The full data URL (e.g., "data:image/png;base64,...")
|
||||
* @returns The base64 data without the prefix
|
||||
*/
|
||||
export function extractBase64Data(dataUrl: string): string {
|
||||
return dataUrl.split(',')[1] || dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
*
|
||||
* @param bytes - File size in bytes
|
||||
* @returns Formatted string (e.g., "1.5 MB")
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an image file for upload
|
||||
*
|
||||
* @param file - The file to validate
|
||||
* @param maxFileSize - Maximum file size in bytes (default: 10MB)
|
||||
* @returns Object with isValid boolean and optional error message
|
||||
*/
|
||||
export function validateImageFile(
|
||||
file: File,
|
||||
maxFileSize: number = DEFAULT_MAX_FILE_SIZE
|
||||
): { isValid: boolean; error?: string } {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique image ID
|
||||
*
|
||||
* @returns A unique ID string for an image attachment
|
||||
*/
|
||||
export function generateImageId(): string {
|
||||
return `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique file ID
|
||||
*
|
||||
* @returns A unique ID string for a file attachment
|
||||
*/
|
||||
export function generateFileId(): string {
|
||||
return `file-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a text file by extension or MIME type
|
||||
*
|
||||
* @param file - The file to check
|
||||
* @returns True if the file is a text file
|
||||
*/
|
||||
export function isTextFile(file: File): boolean {
|
||||
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
const isTextExtension = ACCEPTED_TEXT_EXTENSIONS.includes(extension);
|
||||
const isTextMime = ACCEPTED_TEXT_TYPES.includes(file.type);
|
||||
return isTextExtension || isTextMime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is an image file by MIME type
|
||||
*
|
||||
* @param file - The file to check
|
||||
* @returns True if the file is an image file
|
||||
*/
|
||||
export function isImageFile(file: File): boolean {
|
||||
return ACCEPTED_IMAGE_TYPES.includes(file.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a text file for upload
|
||||
*
|
||||
* @param file - The file to validate
|
||||
* @param maxFileSize - Maximum file size in bytes (default: 1MB)
|
||||
* @returns Object with isValid boolean and optional error message
|
||||
*/
|
||||
export function validateTextFile(
|
||||
file: File,
|
||||
maxFileSize: number = DEFAULT_MAX_TEXT_FILE_SIZE
|
||||
): { isValid: boolean; error?: string } {
|
||||
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||||
|
||||
// Validate file type by extension (MIME types for text files are often unreliable)
|
||||
if (!ACCEPTED_TEXT_EXTENSIONS.includes(extension)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${file.name}: Unsupported file type. Please use .txt or .md files.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text content from a file
|
||||
*
|
||||
* @param file - The file to read
|
||||
* @returns Promise resolving to the text content
|
||||
*/
|
||||
export function fileToText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as text'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MIME type for a text file based on extension
|
||||
*
|
||||
* @param filename - The filename to check
|
||||
* @returns The MIME type for the file
|
||||
*/
|
||||
export function getTextFileMimeType(filename: string): string {
|
||||
const extension = filename.toLowerCase().slice(filename.lastIndexOf('.'));
|
||||
if (extension === '.md') {
|
||||
return 'text/markdown';
|
||||
}
|
||||
return 'text/plain';
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -31,6 +31,39 @@ let staticServer: Server | null = null;
|
||||
const SERVER_PORT = 3008;
|
||||
const STATIC_PORT = 3007;
|
||||
|
||||
// ============================================
|
||||
// Window sizing constants for kanban layout
|
||||
// ============================================
|
||||
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||
// With sidebar collapsed (64px): 1220 + 64 = 1284px
|
||||
const COLUMN_MIN_WIDTH = 280;
|
||||
const COLUMN_COUNT = 4;
|
||||
const GAP_SIZE = 20;
|
||||
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
|
||||
const SIDEBAR_EXPANDED = 288;
|
||||
const SIDEBAR_COLLAPSED = 64;
|
||||
|
||||
const BOARD_CONTENT_MIN =
|
||||
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
|
||||
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
|
||||
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
|
||||
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
|
||||
const DEFAULT_WIDTH = 1600;
|
||||
const DEFAULT_HEIGHT = 950;
|
||||
|
||||
// Window bounds interface (matches @automaker/types WindowBounds)
|
||||
interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
// Debounce timer for saving window bounds
|
||||
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
*/
|
||||
@@ -56,6 +89,120 @@ function getIconPath(): string | null {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to window bounds settings file
|
||||
*/
|
||||
function getWindowBoundsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
*/
|
||||
function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
if (fs.existsSync(boundsPath)) {
|
||||
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
typeof bounds.x === 'number' &&
|
||||
typeof bounds.y === 'number' &&
|
||||
typeof bounds.width === 'number' &&
|
||||
typeof bounds.height === 'number'
|
||||
) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to load window bounds:', (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
*/
|
||||
function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||
console.log('[Electron] Window bounds saved');
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced save of window bounds (500ms delay)
|
||||
*/
|
||||
function scheduleSaveWindowBounds(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
if (saveWindowBoundsTimeout) {
|
||||
clearTimeout(saveWindowBoundsTimeout);
|
||||
}
|
||||
|
||||
saveWindowBoundsTimeout = setTimeout(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that window bounds are visible on at least one display
|
||||
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||
*/
|
||||
function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
const displays = screen.getAllDisplays();
|
||||
|
||||
// Check if window center is visible on any display
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
|
||||
let isVisible = false;
|
||||
for (const display of displays) {
|
||||
const { x, y, width, height } = display.workArea;
|
||||
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
// Window is off-screen, reset to primary display
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { x, y, width, height } = primaryDisplay.workArea;
|
||||
|
||||
return {
|
||||
x: x + Math.floor((width - bounds.width) / 2),
|
||||
y: y + Math.floor((height - bounds.height) / 2),
|
||||
width: Math.min(bounds.width, width),
|
||||
height: Math.min(bounds.height, height),
|
||||
isMaximized: bounds.isMaximized,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure minimum dimensions
|
||||
return {
|
||||
...bounds,
|
||||
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
|
||||
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
*/
|
||||
@@ -246,11 +393,18 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
*/
|
||||
function createWindow(): void {
|
||||
const iconPath = getIconPath();
|
||||
|
||||
// Load and validate saved window bounds
|
||||
const savedBounds = loadWindowBounds();
|
||||
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: 1600,
|
||||
height: 950,
|
||||
minWidth: 1280,
|
||||
minHeight: 768,
|
||||
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||
x: validBounds?.x,
|
||||
y: validBounds?.y,
|
||||
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
|
||||
minHeight: MIN_HEIGHT,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -266,6 +420,11 @@ function createWindow(): void {
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Restore maximized state if previously maximized
|
||||
if (validBounds?.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Load Vite dev server in development or static server in production
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
@@ -280,10 +439,35 @@ function createWindow(): void {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Save window bounds on close, resize, and move
|
||||
mainWindow.on('close', () => {
|
||||
// Save immediately before closing (not debounced)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.on('moved', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
@@ -460,3 +644,17 @@ ipcMain.handle('ping', async () => {
|
||||
ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
|
||||
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
|
||||
|
||||
// If current width is below new minimum, resize window
|
||||
const currentBounds = mainWindow.getBounds();
|
||||
if (currentBounds.width < minWidth) {
|
||||
mainWindow.setSize(minWidth, currentBounds.height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,6 +43,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
||||
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
});
|
||||
|
||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
export type { ThemeMode };
|
||||
|
||||
export type ViewMode =
|
||||
| 'welcome'
|
||||
@@ -202,12 +212,21 @@ export interface ImageAttachment {
|
||||
size?: number; // file size in bytes - optional for messages from server
|
||||
}
|
||||
|
||||
export interface TextFileAttachment {
|
||||
id: string;
|
||||
content: string; // text content of the file
|
||||
mimeType: string; // e.g., "text/plain", "text/markdown"
|
||||
filename: string;
|
||||
size: number; // file size in bytes
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
images?: ImageAttachment[];
|
||||
textFiles?: TextFileAttachment[];
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
@@ -220,6 +239,7 @@ export interface ChatSession {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
// UI-specific: base64-encoded images (not in shared types)
|
||||
export interface FeatureImage {
|
||||
id: string;
|
||||
data: string; // base64 encoded
|
||||
@@ -228,62 +248,25 @@ export interface FeatureImage {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FeatureImagePath {
|
||||
id: string;
|
||||
path: string; // Path to the temp file
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// Available models for feature execution
|
||||
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
||||
export type AgentModel = ClaudeModel;
|
||||
|
||||
// Model provider type
|
||||
export type ModelProvider = 'claude';
|
||||
|
||||
// Thinking level (budget_tokens) options
|
||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
|
||||
// Planning mode for feature specifications
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
// AI Provider Profile - user-defined presets for model configurations
|
||||
export interface AIProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
provider: ModelProvider;
|
||||
isBuiltIn: boolean; // Built-in profiles cannot be deleted
|
||||
icon?: string; // Optional icon name from lucide
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
export interface Feature extends Omit<
|
||||
BaseFeature,
|
||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
||||
> {
|
||||
id: string;
|
||||
title?: string;
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[]; // Required in UI (not optional)
|
||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||
images?: FeatureImage[];
|
||||
imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context
|
||||
startedAt?: string; // ISO timestamp for when the card moved to in_progress
|
||||
skipTests?: boolean; // When true, skip TDD approach and require manual verification
|
||||
summary?: string; // Summary of what was done/modified by the agent
|
||||
model?: AgentModel; // Model to use for this feature (defaults to opus)
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
|
||||
error?: string; // Error message if the agent errored during processing
|
||||
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
|
||||
dependencies?: string[]; // Array of feature IDs this feature depends on
|
||||
// Branch info - worktree path is derived at runtime from branchName
|
||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
||||
planningMode?: PlanningMode; // Planning mode for this feature
|
||||
planSpec?: PlanSpec; // Generated spec/plan data
|
||||
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
||||
prUrl?: string; // Pull request URL when a PR has been created for this feature
|
||||
images?: FeatureImage[]; // UI-specific base64 images
|
||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||
prUrl?: string; // UI-specific: Pull request URL
|
||||
}
|
||||
|
||||
// Parsed task from spec (for spec and full planning modes)
|
||||
@@ -535,6 +518,73 @@ export interface AppState {
|
||||
planContent: string;
|
||||
planningMode: 'lite' | 'spec' | 'full';
|
||||
} | null;
|
||||
|
||||
// Claude Usage Tracking
|
||||
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
|
||||
claudeUsage: ClaudeUsage | null;
|
||||
claudeUsageLastUpdated: number | null;
|
||||
}
|
||||
|
||||
// Claude Usage interface matching the server response
|
||||
export type ClaudeUsage = {
|
||||
sessionTokensUsed: number;
|
||||
sessionLimit: number;
|
||||
sessionPercentage: number;
|
||||
sessionResetTime: string;
|
||||
sessionResetText: string;
|
||||
|
||||
weeklyTokensUsed: number;
|
||||
weeklyLimit: number;
|
||||
weeklyPercentage: number;
|
||||
weeklyResetTime: string;
|
||||
weeklyResetText: string;
|
||||
|
||||
sonnetWeeklyTokensUsed: number;
|
||||
sonnetWeeklyPercentage: number;
|
||||
sonnetResetText: string;
|
||||
|
||||
costUsed: number | null;
|
||||
costLimit: number | null;
|
||||
costCurrency: string | null;
|
||||
|
||||
lastUpdated: string;
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
// Response type for Claude usage API (can be success or error)
|
||||
export type ClaudeUsageResponse = ClaudeUsage | { error: string; message?: string };
|
||||
|
||||
/**
|
||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
*/
|
||||
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
|
||||
if (!claudeUsage) {
|
||||
// No usage data available - don't block
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session limit (5-hour window)
|
||||
if (claudeUsage.sessionPercentage >= 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check weekly limit
|
||||
if (claudeUsage.weeklyPercentage >= 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cost limit (if configured)
|
||||
if (
|
||||
claudeUsage.costLimit !== null &&
|
||||
claudeUsage.costLimit > 0 &&
|
||||
claudeUsage.costUsed !== null &&
|
||||
claudeUsage.costUsed >= claudeUsage.costLimit
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default background settings for board backgrounds
|
||||
@@ -1183,7 +1233,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
addFeature: (feature) => {
|
||||
const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const featureWithId = { ...feature, id } as Feature;
|
||||
const featureWithId = { ...feature, id } as unknown as Feature;
|
||||
set({ features: [...get().features, featureWithId] });
|
||||
return featureWithId;
|
||||
},
|
||||
@@ -2486,6 +2536,15 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||
|
||||
// Claude Usage Tracking actions
|
||||
setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }),
|
||||
setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }),
|
||||
setClaudeUsage: (usage: ClaudeUsage | null) =>
|
||||
set({
|
||||
claudeUsage: usage,
|
||||
claudeUsageLastUpdated: usage ? Date.now() : null,
|
||||
}),
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user