import React from 'react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; 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 { usePromptTemplatesStore } from '@/stores/usePromptTemplatesStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/openchamberConfig'; import type { CreateMultiRunParams, MultiRunGroup } from '@/types/multirun'; import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; import { BranchSelector, useBranchOptions } from './BranchSelector'; import { AgentSelector } from './AgentSelector'; import { Icon } from "@/components/icon/Icon"; import { isDesktopShell } from '@/lib/desktop'; import { useTabletStandalonePwaRuntime } from '@/lib/device'; 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'; const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_MODELS_PER_GROUP = 5; interface MultiRunAttachedFile { id: string; filename: string; mimeType: string; size: number; dataUrl: string; } interface RunGroupState { id: string; templateId: string; prompt: string; models: ModelSelectionWithId[]; } interface MultiRunLauncherProps { initialPrompt?: string; onCreated?: () => void; onCancel?: () => void; isWindowed?: boolean; } const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); const FieldLabel: React.FC<{ htmlFor?: string; required?: boolean; children: React.ReactNode; info?: React.ReactNode; }> = ({ htmlFor, required, children, info }) => (
{info && info}
); export const MultiRunLauncher: React.FC = ({ initialPrompt, onCreated, onCancel, isWindowed = false, }) => { const { t } = useI18n(); const [name, setName] = React.useState(''); const [runGroups, setRunGroups] = React.useState(() => [ { id: generateInstanceId(), templateId: '', prompt: '', models: [] }, ]); 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 [isolateRuns, setIsolateRuns] = React.useState(true); const fileInputRef = React.useRef(null); const templates = usePromptTemplatesStore((s) => s.templates); React.useEffect(() => { usePromptTemplatesStore.getState().loadTemplates(); }, []); 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; }, []); 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 projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined; return ( {imageUrl ? ( ) : projectIconName ? ( ) : ( )} {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] = React.useState(() => (typeof window !== 'undefined' ? isDesktopShell() : false)); const isMacPlatform = React.useMemo(() => { if (typeof navigator === 'undefined') return false; return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); 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; 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) || isTabletStandalonePwa) { // Match main app header: reserve space for Mac/iPadOS traffic lights. return 'pl-[5.5rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]); 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]); 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]); const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState(''); const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(selectedProjectDirectory); const wasIsolationDisabledByNonGitRef = React.useRef(false); const canIsolateRuns = isGitRepository === true; const effectiveIsolateRuns = canIsolateRuns && isolateRuns; React.useEffect(() => { if (isGitRepository === false) { wasIsolationDisabledByNonGitRef.current = true; setIsolateRuns(false); return; } if (isGitRepository === true && wasIsolationDisabledByNonGitRef.current) { wasIsolationDisabledByNonGitRef.current = false; setIsolateRuns(true); } }, [isGitRepository]); 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) { setRunGroups((prev) => { const updated = [...prev]; if (updated.length > 0 && !updated[0].prompt.trim()) { updated[0] = { ...updated[0], prompt: initialPrompt }; } return updated; }); } }, [initialPrompt]); React.useEffect(() => { if (!projectRef) return; let cancelled = false; setIsLoadingSetupCommands(true); (async () => { try { const commands = await getWorktreeSetupCommands(projectRef); if (!cancelled) setSetupCommands(commands); } catch { // Ignore } finally { if (!cancelled) setIsLoadingSetupCommands(false); } })(); return () => { cancelled = true; }; }, [projectRef]); const updateGroup = React.useCallback((groupId: string, updates: Partial) => { setRunGroups((prev) => prev.map((g) => g.id === groupId ? { ...g, ...updates } : g)); }, []); const removeGroup = React.useCallback((groupId: string) => { setRunGroups((prev) => prev.filter((g) => g.id !== groupId)); }, []); const addGroup = React.useCallback(() => { setRunGroups((prev) => [...prev, { id: generateInstanceId(), templateId: '', prompt: '', models: [] }]); }, []); const handleTemplateChange = React.useCallback((groupId: string, templateId: string) => { const template = templateId ? templates.find((t) => t.id === templateId) : null; updateGroup(groupId, { templateId, prompt: template ? template.body : '', }); }, [templates, updateGroup]); 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); }); setAttachedFiles((prev) => [...prev, { id: generateInstanceId(), filename: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, dataUrl, }]); attachedCount++; } catch (err) { console.error('File attach failed', err); 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 totalRunCount = React.useMemo( () => runGroups.reduce((sum, g) => sum + g.models.length, 0), [runGroups], ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const validGroups = runGroups.filter((g) => g.prompt.trim() && g.models.length >= 1); if (validGroups.length === 0) return; if (totalRunCount < 1) return; setIsSubmitting(true); clearError(); try { if (selectedProjectId && selectedProjectId !== activeProjectId) { setActiveProjectIdOnly(selectedProjectId); } const filesForStore = attachedFiles.map((f) => ({ mime: f.mimeType, filename: f.filename, url: f.dataUrl, })); const commandsForStore = setupCommands.filter((cmd) => cmd.trim().length > 0); const groups: MultiRunGroup[] = validGroups.map((g) => ({ prompt: g.prompt.trim(), models: g.models.map((m) => ({ providerID: m.providerID, modelID: m.modelID, displayName: m.displayName, variant: m.variant })), templateId: g.templateId || undefined, })); const params: CreateMultiRunParams = { name: name.trim(), groups, agent: selectedAgent || undefined, worktreeBaseBranch: effectiveIsolateRuns ? worktreeBaseBranch : undefined, isolateRuns: effectiveIsolateRuns, 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); } onCreated?.(); } } finally { setIsSubmitting(false); } }; const isValid = Boolean( name.trim() && runGroups.some((g) => g.prompt.trim() && g.models.length >= 1) && totalRunCount >= 1 && selectedProjectDirectory && !isLoadingWorktreeBaseBranches && (isGitRepository === false || !effectiveIsolateRuns || worktreeBaseBranch) ); const configuredSetupCount = setupCommands.filter((cmd) => cmd.trim()).length; return (
{!isWindowed ? (

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

{onCancel && (

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

)}
) : null}
{t('multirun.launcher.project.label')} {projects.length > 0 ? ( ) : (

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

)}
{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 />
{t('multirun.launcher.baseBranch.info')}}> {t('multirun.launcher.baseBranch.label')}
{t('multirun.launcher.agent.info')}}> {t('multirun.launcher.agent.label')}
{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" />
))} )}
{t('multirun.launcher.attachments.label')}
{t('multirun.launcher.attachments.tooltip')} {attachedFiles.map((file) => (
{file.mimeType.startsWith('image/') ? ( ) : ( )} {file.filename}
))}
{runGroups.map((group, groupIndex) => ( 1} onUpdate={updateGroup} onRemove={removeGroup} onTemplateChange={handleTemplateChange} /> ))} {error && (
{error}
)}
{!effectiveIsolateRuns && isGitRepository !== null ? (
{t('multirun.launcher.project.gitRequired')}
) : null}
); }; interface RunGroupCardProps { group: RunGroupState; groupIndex: number; templates: { id: string; name: string; body: string; isDefault: boolean }[]; canRemove: boolean; onUpdate: (groupId: string, updates: Partial) => void; onRemove: (groupId: string) => void; onTemplateChange: (groupId: string, templateId: string) => void; } const RunGroupCard: React.FC = ({ group, groupIndex, templates, canRemove, onUpdate, onRemove, onTemplateChange, }) => { const { t } = useI18n(); const handleAddModel = React.useCallback((model: ModelSelectionWithId) => { if (group.models.length >= MAX_MODELS_PER_GROUP) return; onUpdate(group.id, { models: [...group.models, model] }); }, [group.id, group.models, onUpdate]); const handleRemoveModel = React.useCallback((index: number) => { onUpdate(group.id, { models: group.models.filter((_, i) => i !== index) }); }, [group.id, group.models, onUpdate]); const handleUpdateModel = React.useCallback((index: number, model: ModelSelectionWithId) => { onUpdate(group.id, { models: group.models.map((item, i) => (i === index ? model : item)) }); }, [group.id, group.models, onUpdate]); return (
{t('multirun.launcher.groups.groupLabel', { index: groupIndex + 1 })} {canRemove && ( )}
{t('multirun.launcher.groups.template.label')}
{t('multirun.launcher.groups.prompt.label')}