import React from 'react'; import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFolderLine, RiInformationLine, RiTerminalLine } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; 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'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete'; import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; import { isDesktopShell } from '@/lib/desktop'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import type { ProjectEntry } from '@/lib/api/types'; import { startDesktopWindowDrag } from '@/lib/desktopNative'; import { useI18n } from '@/lib/i18n'; /** 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; /** Rendered inside dialog window with no local header */ isWindowed?: boolean; } /** Info tooltip - small icon that shows helper text on hover */ const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); /** Compact field label */ const FieldLabel: React.FC<{ htmlFor?: string; required?: boolean; children: React.ReactNode; info?: React.ReactNode; }> = ({ htmlFor, required, children, info }) => (
{info && info}
); /** * Launcher form for creating a new Multi-Run group. * Compact, centered card layout with adaptive grid. */ export const MultiRunLauncher: React.FC = ({ initialPrompt, onCreated, onCancel, isWindowed = false, }) => { const { t } = useI18n(); 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 [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); const [commandQuery, setCommandQuery] = React.useState(''); const fileInputRef = React.useRef(null); const promptTextareaRef = React.useRef(null); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const homeDirectory = useDirectoryStore((state) => state.homeDirectory ?? 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 setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const projects = useProjectsStore((state) => state.projects); const [selectedProjectId, setSelectedProjectId] = React.useState(() => activeProjectId ?? null); React.useEffect(() => { if (activeProjectId) { setSelectedProjectId(activeProjectId); return; } if (!selectedProjectId && projects.length > 0) { setSelectedProjectId(projects[0].id); } }, [activeProjectId, projects, selectedProjectId]); const selectedProject = React.useMemo(() => { if (!selectedProjectId) { return null; } return projects.find((project) => project.id === selectedProjectId) ?? null; }, [projects, selectedProjectId]); const selectedProjectDirectory = selectedProject?.path ?? currentDirectory; const handleProjectChange = React.useCallback((projectId: string) => { setSelectedProjectId(projectId); if (projectId !== activeProjectId) { setActiveProjectIdOnly(projectId); } }, [activeProjectId, setActiveProjectIdOnly]); const { currentTheme } = useThemeSystem(); const renderProjectLabel = React.useCallback((project: ProjectEntry) => { const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory); const imageUrl = getProjectIconImageUrl( { id: project.id, iconImage: project.iconImage ?? null }, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }, ); const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined; return ( {imageUrl ? ( ) : ProjectIcon ? ( ) : ( )} {displayLabel} ); }, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]); const projectRef = React.useMemo(() => { if (selectedProject?.path) { return { id: selectedProject.id, path: selectedProject.path }; } const base = currentDirectory ?? vscodeWorkspaceFolder; if (!base) { return null; } return { id: `path:${base}`, path: base }; }, [selectedProject, currentDirectory, vscodeWorkspaceFolder]); const [isDesktopApp, setIsDesktopApp] = React.useState(() => { if (typeof window === 'undefined') { return false; } return isDesktopShell(); }); 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; } setIsDesktopApp(isDesktopShell()); }, []); const macosMajorVersion = React.useMemo(() => { if (typeof window === 'undefined') { return null; } const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__; if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) { return injected; } // Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix if (typeof navigator === 'undefined') { return null; } const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/); if (!match) { return null; } const first = Number.parseInt(match[1], 10); const second = Number.parseInt(match[2], 10); if (Number.isNaN(first)) { return null; } return first === 10 ? second : first; }, []); const desktopHeaderPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { // Match main app header: reserve space for Mac traffic lights. return 'pl-[5.5rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform]); const macosHeaderSizeClass = React.useMemo(() => { if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) { return ''; } if (macosMajorVersion >= 26) { return 'h-12'; } if (macosMajorVersion <= 15) { return 'h-14'; } return ''; }, [isDesktopApp, isMacPlatform, macosMajorVersion]); 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) { await startDesktopWindowDrag(); } }, [isDesktopApp]); // Handle ESC key to dismiss React.useEffect(() => { if (!onCancel) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } }; window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, [onCancel]); // Use the BranchSelector hook for branch state management const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState(''); const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(selectedProjectDirectory); 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(t('multirun.launcher.toast.fileTooLarge', { fileName: file.name })); 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(t('multirun.launcher.toast.attachFailed', { fileName: file.name })); } } if (attachedCount > 0) { toast.success( attachedCount === 1 ? t('multirun.launcher.toast.attachedSingle', { count: attachedCount }) : t('multirun.launcher.toast.attachedPlural', { count: attachedCount }) ); } if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleRemoveFile = (id: string) => { setAttachedFiles((prev) => prev.filter((f) => f.id !== id)); }; const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { if (value.startsWith('/')) { const firstSpace = value.indexOf(' '); const firstNewline = value.indexOf('\n'); const commandEnd = Math.min( firstSpace === -1 ? value.length : firstSpace, firstNewline === -1 ? value.length : firstNewline, ); if (cursorPosition <= commandEnd && firstSpace === -1) { setCommandQuery(value.substring(1, commandEnd)); setShowCommandAutocomplete(true); setShowFileMention(false); return; } } setShowCommandAutocomplete(false); const textBeforeCursor = value.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); setShowFileMention(true); } else { setShowFileMention(false); } return; } setShowFileMention(false); }, []); const handlePromptKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (showCommandAutocomplete && commandRef.current) { if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); commandRef.current.handleKeyDown(event.key); return; } } if (showFileMention && mentionRef.current) { if (event.key === 'Enter' || event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); mentionRef.current.handleKeyDown(event.key); } } }, [showCommandAutocomplete, showFileMention]); const handleAutocompleteFileSelect = React.useCallback((file: { name: string; path: string; relativePath?: string }) => { const textarea = promptTextareaRef.current; const cursorPosition = textarea?.selectionStart ?? prompt.length; const textBeforeCursor = prompt.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) ? file.relativePath.trim() : (file.path || file.name); const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; const nextPrompt = `${prompt.substring(0, startIndex)}@${mentionPath} ${prompt.substring(cursorPosition)}`; const nextCursor = startIndex + mentionPath.length + 2; setPrompt(nextPrompt); setShowFileMention(false); setMentionQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = nextCursor; currentTextarea.selectionEnd = nextCursor; currentTextarea.focus(); } updateAutocompleteState(nextPrompt, nextCursor); }); }, [prompt, updateAutocompleteState]); const handleAutocompleteAgentSelect = React.useCallback((agentName: string) => { const textarea = promptTextareaRef.current; const cursorPosition = textarea?.selectionStart ?? prompt.length; const textBeforeCursor = prompt.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const startIndex = lastAtSymbol !== -1 ? lastAtSymbol : cursorPosition; const nextPrompt = `${prompt.substring(0, startIndex)}@${agentName} ${prompt.substring(cursorPosition)}`; const nextCursor = startIndex + agentName.length + 2; setPrompt(nextPrompt); setShowFileMention(false); setMentionQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = nextCursor; currentTextarea.selectionEnd = nextCursor; currentTextarea.focus(); } updateAutocompleteState(nextPrompt, nextCursor); }); }, [prompt, updateAutocompleteState]); const handleAutocompleteCommandSelect = React.useCallback((command: CommandInfo) => { const nextPrompt = `/${command.name} `; setPrompt(nextPrompt); setShowCommandAutocomplete(false); setCommandQuery(''); requestAnimationFrame(() => { const currentTextarea = promptTextareaRef.current; if (currentTextarea) { currentTextarea.focus(); currentTextarea.selectionStart = currentTextarea.value.length; currentTextarea.selectionEnd = currentTextarea.value.length; } updateAutocompleteState(nextPrompt, nextPrompt.length); }); }, [updateAutocompleteState]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!prompt.trim()) { return; } if (selectedModels.length < 2) { return; } setIsSubmitting(true); clearError(); try { if (selectedProjectId && selectedProjectId !== activeProjectId) { setActiveProjectIdOnly(selectedProjectId); } // 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) { useSessionUIStore.getState().setCurrentSession(result.firstSessionId); } // Close launcher onCreated?.(); } } finally { setIsSubmitting(false); } }; const isValid = Boolean( name.trim() && prompt.trim() && selectedModels.length >= 2 && worktreeBaseBranch && isGitRepository && !isLoadingWorktreeBaseBranches ); const configuredSetupCount = setupCommands.filter(cmd => cmd.trim()).length; return (
{!isWindowed ? (

{t('multirun.launcher.title')}

{onCancel && (

{t('multirun.launcher.actions.closeEsc')}

)}
) : null} {/* Scrollable content */}
{/* ── Config grid: 2-column on sm+, single column on narrow ── */}
{/* Project */}
{t('multirun.launcher.project.label')} {projects.length > 0 ? ( ) : (

{t('multirun.launcher.project.empty')}

)}
{/* Group name */}
{t('multirun.launcher.groupName.info')}} > {t('multirun.launcher.groupName.label')} setName(e.target.value)} placeholder={t('multirun.launcher.groupName.placeholder')} className="typography-meta w-full" required />
{/* Base branch */}
{t('multirun.launcher.baseBranch.info')}} > {t('multirun.launcher.baseBranch.label')}
{/* Agent */}
{t('multirun.launcher.agent.info')}} > {t('multirun.launcher.agent.label')}
{/* ── Setup commands (collapsible, full width) ── */} {t('multirun.launcher.setupCommands.label')} {configuredSetupCount > 0 && ( {configuredSetupCount} )}
{isLoadingSetupCommands ? (

{t('multirun.launcher.setupCommands.loading')}

) : ( <> {setupCommands.map((command, index) => (
{ const newCommands = [...setupCommands]; newCommands[index] = e.target.value; setSetupCommands(newCommands); }} placeholder={t('multirun.launcher.setupCommands.commandPlaceholder')} className="h-8 flex-1 font-mono text-xs" />
))} )}
{/* ── Prompt ── */}
{t('multirun.launcher.prompt.label')}