import React from 'react'; import { RiAddCircleLine, RiAddLine, RiArrowDownSLine, RiCloseLine, RiFileImageLine, RiFileLine, RiGitBranchLine, RiHourglassFill, RiSendPlane2Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect'; import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector'; import { AgentSelector } from '@/components/multirun/AgentSelector'; import { isIMECompositionEvent } from '@/lib/ime'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ProjectRef } from '@/lib/openchamberConfig'; import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun'; /** 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 agent manager */ interface AttachedFile { id: string; filename: string; mimeType: string; size: number; dataUrl: string; } interface AgentManagerEmptyStateProps { className?: string; /** Called when the user submits to create a new agent group */ onCreateGroup?: (params: CreateMultiRunParams) => Promise | void; /** Indicates if a group creation is in progress */ isCreating?: boolean; } export const AgentManagerEmptyState: React.FC = ({ className, onCreateGroup, isCreating = false, }) => { const [groupName, setGroupName] = React.useState(''); const [prompt, setPrompt] = React.useState(''); const [selectedModels, setSelectedModels] = React.useState([]); const [selectedAgent, setSelectedAgent] = React.useState(''); const [baseBranch, setBaseBranch] = React.useState('HEAD'); 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 textareaRef = React.useRef(null); const { currentTheme } = useThemeSystem(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory); 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; }, []); const isVSCodeRuntime = React.useMemo(() => { if (typeof window === 'undefined') { return false; } const apis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__; return Boolean(apis?.runtime?.isVSCode); }, []); // Get project directory for setup commands const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const projectRef = React.useMemo(() => { // VS Code panel should always use the current workspace root. if (isVSCodeRuntime && vscodeWorkspaceFolder) { return { id: `vscode:${vscodeWorkspaceFolder}`, path: vscodeWorkspaceFolder }; } if (activeProjectId) { const project = projects.find((p) => p.id === activeProjectId); if (project?.path) { return { id: project.id, path: project.path }; } } if (currentDirectory) { return { id: `path:${currentDirectory}`, path: currentDirectory }; } return null; }, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder, isVSCodeRuntime]); // 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 = React.useCallback((model: ModelSelectionWithId) => { if (selectedModels.length >= MAX_MODELS) { return; } setSelectedModels((prev) => [...prev, model]); }, [selectedModels.length]); const handleRemoveModel = React.useCallback((index: number) => { setSelectedModels((prev) => prev.filter((_, i) => i !== index)); }, []); 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: AttachedFile = { 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)); }; // Use either local submitting state or external isCreating prop const isSubmittingOrCreating = isSubmitting || isCreating; const isValid = Boolean( groupName.trim() && prompt.trim() && selectedModels.length >= 1 && isGitRepository && !isLoadingBranches ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isValid || isSubmittingOrCreating) return; setIsSubmitting(true); try { const models = selectedModels.map(({ providerID, modelID, displayName, variant }) => ({ providerID, modelID, displayName, variant, })); const files: MultiRunFileAttachment[] | undefined = attachedFiles.length > 0 ? attachedFiles.map((f) => ({ mime: f.mimeType, filename: f.filename, url: f.dataUrl, })) : undefined; // Filter setup commands const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0); await onCreateGroup?.({ name: groupName.trim(), prompt: prompt.trim(), models, agent: selectedAgent || undefined, worktreeBaseBranch: baseBranch, files, setupCommands: commandsToRun.length > 0 ? commandsToRun : undefined, }); // Reset form on success - only after onCreateGroup completes setGroupName(''); setPrompt(''); setSelectedModels([]); setSelectedAgent(''); setAttachedFiles([]); setBaseBranch('HEAD'); } catch (error) { console.error('Failed to create agent group:', error); toast.error('Failed to create agent group'); } finally { setIsSubmitting(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition if (isIMECompositionEvent(e)) return; // Enter submits if valid, Shift+Enter adds newline if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (isValid && !isSubmittingOrCreating) { handleSubmit(e as unknown as React.FormEvent); } // If not valid, do nothing (no newline, no submit) } // Shift+Enter: default textarea behavior (adds newline) }; return (
{/* Group Name Input */}
setGroupName(e.target.value)} placeholder="e.g. feature-auth, bugfix-login" className="typography-body" />

Used for worktree directory and branch naming

{/* Branch Selection */}

Creates new branches from {baseBranch}

{/* 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

{/* Model Selection */}
{/* Chat Input Style Prompt */}
{/* Text Area */}