import React from 'react'; import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine } from '@remixicon/react'; import { toast } from '@/components/ui'; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/openchamberConfig'; 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 [setupCommands, setSetupCommands] = React.useState([]); const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); const fileInputRef = React.useRef(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const vscodeWorkspaceFolder = React.useMemo(() => { if (typeof window === 'undefined') { return null; } const folder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null; }, []); // Get project directory for setup commands const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const projectRef = React.useMemo(() => { if (activeProjectId) { const project = projects.find((p) => p.id === activeProjectId); if (project?.path) { return { id: project.id, path: project.path }; } } const base = currentDirectory ?? vscodeWorkspaceFolder; if (!base) { return null; } return { id: `path:${base}`, path: base }; }, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]); 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) { // Match main app header: reserve space for Mac traffic lights. return 'pl-[5.125rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform]); const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) { return; } if (e.button !== 0) { return; } if (isDesktopApp) { try { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const window = getCurrentWindow(); await window.startDragging(); } catch { // ignore } } }, [isDesktopApp]); // 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]); // Load setup commands from config React.useEffect(() => { if (!projectRef) return; let cancelled = false; setIsLoadingSetupCommands(true); (async () => { try { const commands = await getWorktreeSetupCommands(projectRef); if (!cancelled) { setSetupCommands(commands); } } catch { // Ignore errors, start with empty commands } finally { if (!cancelled) { setIsLoadingSetupCommands(false); } } })(); return () => { cancelled = true; }; }, [projectRef]); 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 handleUpdateModel = React.useCallback((index: number, model: ModelSelectionWithId) => { setSelectedModels((prev) => prev.map((item, i) => (i === index ? model : item))); }, []); 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, })); // Filter setup commands const commandsForStore = setupCommands.filter(cmd => cmd.trim().length > 0); const params: CreateMultiRunParams = { name: name.trim(), prompt: prompt.trim(), models: modelsForStore, agent: selectedAgent || undefined, worktreeBaseBranch, files: filesForStore.length > 0 ? filesForStore : undefined, setupCommands: commandsForStore.length > 0 ? commandsForStore : 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'}.

{/* Setup commands collapsible */}

Setup commands {setupCommands.filter(cmd => cmd.trim()).length > 0 && ( {' '}({setupCommands.filter(cmd => cmd.trim()).length} configured) )}

Commands run in each new worktree. Use $ROOT_PROJECT_PATH for project root.

{isLoadingSetupCommands ? (

Loading...

) : (
{setupCommands.map((command, index) => (
{ const newCommands = [...setupCommands]; newCommands[index] = e.target.value; setSetupCommands(newCommands); }} placeholder="e.g., bun install" className="h-8 flex-1 font-mono text-xs" />
))}
)}
{/* Agent selection */}

Defaults to your configured default agent.

{/* Prompt */}