import React from 'react'; import { RiAddLine, RiCheckLine, RiCloseLine, RiPlayLine, RiSearchLine, RiStarFill, RiTimeLine } 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 { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; import type { ModelMetadata } from '@/types'; 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}`}
); }; const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1, minimumFractionDigits: 0, }); const formatTokens = (value?: number | null) => { if (typeof value !== 'number' || Number.isNaN(value)) { return ''; } if (value === 0) { return '0'; } const formatted = COMPACT_NUMBER_FORMATTER.format(value); return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; }; /** * 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, modelsMetadata } = useConfigStore(); const { favoriteModelsList, recentModelsList } = useModelLists(); const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); const searchInputRef = React.useRef(null); const dropdownRef = React.useRef(null); const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]); // Get set of already selected model keys const selectedKeys = React.useMemo(() => { return new Set(selectedModels.map((m) => `${m.providerID}:${m.modelID}`)); }, [selectedModels]); const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { const key = `${provId}/${modId}`; return modelsMetadata.get(key); }; const getModelDisplayName = (model: Record) => { const name = model?.name || model?.id || ''; const nameStr = String(name); if (nameStr.length > 40) { return nameStr.substring(0, 37) + '...'; } return nameStr; }; // Filter helper const filterByQuery = React.useCallback((modelName: string, providerName: string) => { if (!searchQuery.trim()) return true; const lowerQuery = searchQuery.toLowerCase(); return ( modelName.toLowerCase().includes(lowerQuery) || providerName.toLowerCase().includes(lowerQuery) ); }, [searchQuery]); // Filter favorites const filteredFavorites = React.useMemo(() => { return favoriteModelsList.filter(({ model, providerID }) => { const provider = providers.find(p => p.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return filterByQuery(modelName, providerName); }); }, [favoriteModelsList, providers, filterByQuery]); // Filter recents const filteredRecents = React.useMemo(() => { return recentModelsList.filter(({ model, providerID }) => { const provider = providers.find(p => p.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return filterByQuery(modelName, providerName); }); }, [recentModelsList, providers, filterByQuery]); // Filter providers const filteredProviders = React.useMemo(() => { return providers .map((provider) => { const models = Array.isArray(provider.models) ? provider.models : []; const filteredModels = models.filter((model) => { const modelName = getModelDisplayName(model); return filterByQuery(modelName, provider.name || provider.id || ''); }); return { ...provider, models: filteredModels }; }) .filter((provider) => provider.models.length > 0); }, [providers, filterByQuery]); const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; // 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(''); setSelectedIndex(0); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen]); // Reset selection when search query changes React.useEffect(() => { setSelectedIndex(0); }, [searchQuery]); // Render a model row const renderModelRow = ( model: Record, providerID: string, modelID: string, keyPrefix: string, flatIndex: number, isHighlighted: boolean ) => { const key = `${providerID}:${modelID}`; const isSelected = selectedKeys.has(key); const metadata = getModelMetadata(providerID, modelID); const contextTokens = formatTokens(metadata?.limit?.context); return ( ); }; return (
{/* Add model button (dropdown trigger) */}
{isOpen && (() => { // Build flat list for keyboard navigation type FlatModelItem = { model: Record; providerID: string; modelID: string; section: string }; const flatModelList: FlatModelItem[] = []; filteredFavorites.forEach(({ model, providerID, modelID }) => { flatModelList.push({ model, providerID, modelID, section: 'fav' }); }); filteredRecents.forEach(({ model, providerID, modelID }) => { flatModelList.push({ model, providerID, modelID, section: 'recent' }); }); filteredProviders.forEach((provider) => { provider.models.forEach((model) => { flatModelList.push({ model, providerID: provider.id, modelID: model.id as string, section: 'provider' }); }); }); const totalItems = flatModelList.length; // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); setSelectedIndex(nextIndex); setTimeout(() => { itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 0); } else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); setSelectedIndex(prevIndex); setTimeout(() => { itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 0); } else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); const selectedItem = flatModelList[selectedIndex]; if (selectedItem && !selectedKeys.has(`${selectedItem.providerID}:${selectedItem.modelID}`)) { onAdd({ providerID: selectedItem.providerID, modelID: selectedItem.modelID, displayName: (selectedItem.model.name as string) || selectedItem.modelID, }); } } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setIsOpen(false); setSearchQuery(''); setSelectedIndex(0); } }; let currentFlatIndex = 0; return (
{/* Search input */}
setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} className="h-8 pl-8 typography-meta" />
{/* Models list */}
{!hasResults && (
No models found
)} {/* Favorites Section */} {filteredFavorites.length > 0 && ( <>
Favorites
{filteredFavorites.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); })} )} {/* Recents Section */} {filteredRecents.length > 0 && ( <> {filteredFavorites.length > 0 &&
}
Recent
{filteredRecents.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); })} )} {/* Separator before providers */} {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && (
)} {/* All Providers - Flat List */} {filteredProviders.map((provider, index) => ( {index > 0 &&
}
{provider.name}
{provider.models.map((model) => { const idx = currentFlatIndex++; return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx); })} ))}
{/* Keyboard hints footer */}
↑↓ navigate • Enter select • Esc close
); })()}
{/* 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 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]); 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 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'}.

{isGitRepository === false ? (

Not in a git repository.

) : null}
{/* Prompt */}