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 */}