import React from 'react'; import { RiAddLine, RiCloseLine, RiPlayLine, RiSearchLine } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionStore } from '@/stores/useSessionStore'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; interface MultiRunLauncherProps { /** Prefill prompt textarea (optional) */ initialPrompt?: string; /** Called when multi-run is successfully created */ onCreated?: () => void; /** Called when user cancels */ onCancel?: () => void; } /** Chip height class - shared between chips and add button */ const CHIP_HEIGHT_CLASS = 'h-7'; type WorktreeBaseOption = { value: string; label: string; group: 'special' | 'local' | 'remote'; }; /** * Model selection chip with remove button. */ const ModelChip: React.FC<{ model: MultiRunModelSelection; onRemove: () => void; }> = ({ model, onRemove }) => { return (
{model.displayName || `${model.providerID}/${model.modelID}`}
); }; /** * Model selector for multi-run (allows selecting multiple unique models). */ const ModelMultiSelect: React.FC<{ selectedModels: MultiRunModelSelection[]; onAdd: (model: MultiRunModelSelection) => void; onRemove: (index: number) => void; }> = ({ selectedModels, onAdd, onRemove }) => { const providers = useConfigStore((state) => state.providers); const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const searchInputRef = React.useRef(null); const dropdownRef = React.useRef(null); // Get set of already selected model keys const selectedKeys = React.useMemo(() => { return new Set(selectedModels.map((m) => `${m.providerID}:${m.modelID}`)); }, [selectedModels]); // Filter models based on search query const filteredProviders = React.useMemo(() => { if (!searchQuery.trim()) return providers; const query = searchQuery.toLowerCase(); return providers .map((provider) => { const models = Array.isArray(provider.models) ? provider.models : []; const filteredModels = models.filter((model) => { const modelName = (model.name || model.id || '').toString().toLowerCase(); const providerName = provider.name.toLowerCase(); return modelName.includes(query) || providerName.includes(query); }); return { ...provider, models: filteredModels }; }) .filter((provider) => provider.models.length > 0); }, [providers, searchQuery]); // Focus search input when opened React.useEffect(() => { if (isOpen && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen]); // Close dropdown when clicking outside React.useEffect(() => { if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); setSearchQuery(''); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen]); return (
{/* Add model button (dropdown trigger) */}
{isOpen && (
{/* Search input */}
setSearchQuery(e.target.value)} className="h-8 pl-8 typography-meta" />
{/* Models list */} {filteredProviders.length === 0 ? (
No models found
) : ( filteredProviders.map((provider) => { const models = Array.isArray(provider.models) ? provider.models : []; if (models.length === 0) return null; return (
{provider.name}
{models.map((model) => { const key = `${provider.id}:${model.id}`; const isSelected = selectedKeys.has(key); return ( ); })}
); }) )}
)}
{/* Selected models */} {selectedModels.map((model, index) => ( onRemove(index)} /> ))}
); }; /** * 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 [isSubmitting, setIsSubmitting] = React.useState(false); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState([ { value: 'HEAD', label: 'Current (HEAD)', group: 'special' }, ]); const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false); const [isGitRepository, setIsGitRepository] = React.useState(null); 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]); React.useEffect(() => { let cancelled = false; if (!currentDirectory) { setIsGitRepository(null); setIsLoadingWorktreeBaseBranches(false); setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); setWorktreeBaseBranch('HEAD'); return; } setIsLoadingWorktreeBaseBranches(true); setIsGitRepository(null); (async () => { try { const isGit = await checkIsGitRepository(currentDirectory); if (cancelled) return; setIsGitRepository(isGit); if (!isGit) { setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]); setWorktreeBaseBranch('HEAD'); return; } const branches = await getGitBranches(currentDirectory).catch(() => null); if (cancelled) return; const worktreeBaseOptions: WorktreeBaseOption[] = []; const headLabel = branches?.current ? `Current (HEAD: ${branches.current})` : 'Current (HEAD)'; worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' }); if (branches) { const localBranches = branches.all .filter((branchName) => !branchName.startsWith('remotes/')) .sort((a, b) => a.localeCompare(b)); localBranches.forEach((branchName) => { worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' }); }); const remoteBranches = branches.all .filter((branchName) => branchName.startsWith('remotes/')) .map((branchName) => branchName.replace(/^remotes\//, '')) .sort((a, b) => a.localeCompare(b)); remoteBranches.forEach((branchName) => { worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' }); }); } setAvailableWorktreeBaseBranches(worktreeBaseOptions); setWorktreeBaseBranch((previous) => worktreeBaseOptions.some((option) => option.value === previous) ? previous : 'HEAD' ); } finally { if (!cancelled) { setIsLoadingWorktreeBaseBranches(false); } } })(); return () => { cancelled = true; }; }, [currentDirectory]); const handleAddModel = (model: MultiRunModelSelection) => { const key = `${model.providerID}:${model.modelID}`; if (selectedModels.some((m) => `${m.providerID}:${m.modelID}` === key)) { return; } setSelectedModels((prev) => [...prev, model]); clearError(); }; const handleRemoveModel = (index: number) => { setSelectedModels((prev) => prev.filter((_, i) => i !== index)); clearError(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!prompt.trim()) { return; } if (selectedModels.length < 2) { return; } setIsSubmitting(true); clearError(); try { const params: CreateMultiRunParams = { name: name.trim(), prompt: prompt.trim(), models: selectedModels, worktreeBaseBranch, }; 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" 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'}.

{isGitRepository === false ? (

Not in a git repository.

) : null}
{/* Prompt */}