import React from 'react'; import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; import { BranchSelector, useBranchOptions } from './BranchSelector'; import { AgentSelector } from './AgentSelector'; /** Max file size in bytes (10MB) */ const MAX_FILE_SIZE = 10 * 1024 * 1024; /** Max number of concurrent runs */ const MAX_MODELS = 5; /** Attached file for multi-run (simplified from sessionStore's AttachedFile) */ interface MultiRunAttachedFile { id: string; filename: string; mimeType: string; size: number; dataUrl: string; } interface MultiRunLauncherProps { /** Prefill prompt textarea (optional) */ initialPrompt?: string; /** Called when multi-run is successfully created */ onCreated?: () => void; /** Called when user cancels */ onCancel?: () => void; } /** * Launcher form for creating a new Multi-Run group. * Replaces the main content area (tabs) with a form. */ export const MultiRunLauncher: React.FC = ({ initialPrompt, onCreated, onCancel, }) => { const [name, setName] = React.useState(''); const [prompt, setPrompt] = React.useState(() => initialPrompt ?? ''); const [selectedModels, setSelectedModels] = React.useState([]); const [selectedAgent, setSelectedAgent] = 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); const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') { return false; } return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; }); const isMacPlatform = React.useMemo(() => { if (typeof navigator === 'undefined') { return false; } return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); React.useEffect(() => { if (typeof window === 'undefined') { return; } const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined'; setIsDesktopApp(detected); }, []); const desktopHeaderPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform, isSidebarOpen]); // Use the BranchSelector hook for branch state management const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory); const createMultiRun = useMultiRunStore((state) => state.createMultiRun); const error = useMultiRunStore((state) => state.error); const clearError = useMultiRunStore((state) => state.clearError); React.useEffect(() => { if (typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) { setPrompt((prev) => (prev.trim().length > 0 ? prev : initialPrompt)); } }, [initialPrompt]); const handleAddModel = (model: ModelSelectionWithId) => { if (selectedModels.length >= MAX_MODELS) { return; } setSelectedModels((prev) => [...prev, model]); clearError(); }; const handleRemoveModel = (index: number) => { setSelectedModels((prev) => prev.filter((_, i) => i !== index)); 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(); if (!prompt.trim()) { return; } if (selectedModels.length < 2) { return; } setIsSubmitting(true); 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: modelsForStore, agent: selectedAgent || undefined, worktreeBaseBranch, files: filesForStore.length > 0 ? filesForStore : undefined, }; const result = await createMultiRun(params); if (result) { if (result.firstSessionId) { useSessionStore.getState().setCurrentSession(result.firstSessionId); } // Close launcher onCreated?.(); } } finally { setIsSubmitting(false); } }; const isValid = Boolean( name.trim() && prompt.trim() && selectedModels.length >= 2 && isGitRepository && !isLoadingWorktreeBaseBranches ); return (
{/* Header - same height as app header (h-12 = 48px) */}

New Multi-Run

{onCancel && (

Close

)}
{/* Content with chat-column max-width */}
{/* Group name (required) */}
setName(e.target.value)} placeholder="e.g. feature-auth, bugfix-login" className="typography-body max-w-full sm:max-w-xs" required />

Used for worktree directory and branch names

{/* Worktree creation */}

Worktrees

Create one worktree per model by creating a new branch from a base branch.

Creates new branches from{' '} {worktreeBaseBranch || 'HEAD'}.

{/* Agent selection */}

Defaults to your configured default agent.

{/* Prompt */}