fix conflicts

This commit is contained in:
Test User
2025-12-22 12:15:48 -05:00
46 changed files with 4455 additions and 823 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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]);
}

View File

@@ -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>
)}

View File

@@ -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,

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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'

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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));

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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

View 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';
}

View File

@@ -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);
}
});

View File

@@ -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)');

View File

@@ -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),
}),