diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index afb87cbb..4f705cdd 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { RiAddLine, RiCheckLine, RiCloseLine, RiPlayLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react'; +import { RiAddLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -27,6 +28,25 @@ import { useModelLists } from '@/hooks/useModelLists'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; import type { ModelMetadata } from '@/types'; +/** Max file size in bytes (10MB) */ +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +/** Attached file for multi-run (simplified from sessionStore's AttachedFile) */ +interface MultiRunAttachedFile { + id: string; + filename: string; + mimeType: string; + size: number; + dataUrl: string; +} + +/** UI-only type with instanceId for React keys and duplicate tracking */ +type ModelSelectionWithId = MultiRunModelSelection & { instanceId: string }; + +const generateInstanceId = (): string => { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +}; + interface MultiRunLauncherProps { /** Prefill prompt textarea (optional) */ initialPrompt?: string; @@ -47,16 +67,22 @@ type WorktreeBaseOption = { /** * Model selection chip with remove button. + * Shows instance index (e.g., "(2)") when same model is selected multiple times. */ const ModelChip: React.FC<{ - model: MultiRunModelSelection; + model: ModelSelectionWithId; + instanceIndex: number; + totalSameModel: number; onRemove: () => void; -}> = ({ model, onRemove }) => { +}> = ({ model, instanceIndex, totalSameModel, onRemove }) => { + const displayName = model.displayName || `${model.providerID}/${model.modelID}`; + const label = totalSameModel > 1 ? `${displayName} (${instanceIndex})` : displayName; + return (
- {model.displayName || `${model.providerID}/${model.modelID}`} + {label} ); @@ -309,11 +346,12 @@ const ModelMultiSelect: React.FC<{ e.preventDefault(); e.stopPropagation(); const selectedItem = flatModelList[selectedIndex]; - if (selectedItem && !selectedKeys.has(`${selectedItem.providerID}:${selectedItem.modelID}`)) { + if (selectedItem) { onAdd({ providerID: selectedItem.providerID, modelID: selectedItem.modelID, displayName: (selectedItem.model.name as string) || selectedItem.modelID, + instanceId: generateInstanceId(), }); } } else if (e.key === 'Escape') { @@ -418,13 +456,20 @@ const ModelMultiSelect: React.FC<{
{/* Selected models */} - {selectedModels.map((model, index) => ( - onRemove(index)} - /> - ))} + {selectedModels.map((model, index) => { + const key = `${model.providerID}:${model.modelID}`; + const totalSameModel = modelCounts.get(key) || 1; + const instanceIndex = getInstanceIndex(model); + return ( + onRemove(index)} + /> + ); + })} ); @@ -441,8 +486,10 @@ export const MultiRunLauncher: React.FC = ({ }) => { const [name, setName] = React.useState(''); const [prompt, setPrompt] = React.useState(() => initialPrompt ?? ''); - const [selectedModels, setSelectedModels] = React.useState([]); + const [selectedModels, setSelectedModels] = React.useState([]); + const [attachedFiles, setAttachedFiles] = React.useState([]); const [isSubmitting, setIsSubmitting] = React.useState(false); + const fileInputRef = React.useRef(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); @@ -561,11 +608,7 @@ export const MultiRunLauncher: React.FC = ({ }, [currentDirectory]); - const handleAddModel = (model: MultiRunModelSelection) => { - const key = `${model.providerID}:${model.modelID}`; - if (selectedModels.some((m) => `${m.providerID}:${m.modelID}` === key)) { - return; - } + const handleAddModel = (model: ModelSelectionWithId) => { setSelectedModels((prev) => [...prev, model]); clearError(); }; @@ -575,6 +618,55 @@ export const MultiRunLauncher: React.FC = ({ clearError(); }; + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + + let attachedCount = 0; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.size > MAX_FILE_SIZE) { + toast.error(`File "${file.name}" is too large (max 10MB)`); + continue; + } + + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const newFile: MultiRunAttachedFile = { + id: generateInstanceId(), + filename: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + dataUrl, + }; + + setAttachedFiles((prev) => [...prev, newFile]); + attachedCount++; + } catch (error) { + console.error('File attach failed', error); + toast.error(`Failed to attach "${file.name}"`); + } + } + + if (attachedCount > 0) { + toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); + } + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleRemoveFile = (id: string) => { + setAttachedFiles((prev) => prev.filter((f) => f.id !== id)); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -590,11 +682,23 @@ export const MultiRunLauncher: React.FC = ({ clearError(); try { + // Strip instanceId before passing to store (UI-only field) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const modelsForStore: MultiRunModelSelection[] = selectedModels.map(({ instanceId: _instanceId, ...rest }) => rest); + + // Convert attached files to the format expected by the store + const filesForStore = attachedFiles.map((f) => ({ + mime: f.mimeType, + filename: f.filename, + url: f.dataUrl, + })); + const params: CreateMultiRunParams = { name: name.trim(), prompt: prompt.trim(), - models: selectedModels, + models: modelsForStore, worktreeBaseBranch, + files: filesForStore.length > 0 ? filesForStore : undefined, }; const result = await createMultiRun(params); @@ -776,6 +880,64 @@ export const MultiRunLauncher: React.FC = ({ /> + {/* File attachments */} +
+
+ + (optional, same files for all runs) +
+ + + +
+ + + {attachedFiles.map((file) => ( +
+ {file.mimeType.startsWith('image/') ? ( + + ) : ( + + )} + + {file.filename} + + + ({file.size < 1024 ? `${file.size}B` : file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)}KB` : `${(file.size / (1024 * 1024)).toFixed(1)}MB`}) + + +
+ ))} +
+
+ {/* Model selection */}