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 */}
{/* 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 (
{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 && (
)}
{/* Content with chat-column max-width */}
);
};