diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 27977fdd..776f339c 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -13,14 +13,13 @@ 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, MultiRunModelSelection } from '@/types/multirun'; +import type { CreateMultiRunParams, MultiRunGroup } 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 { Icon } from "@/components/icon/Icon"; import { isDesktopShell } from '@/lib/desktop'; import { useTabletStandalonePwaRuntime } from '@/lib/device'; @@ -30,13 +29,9 @@ 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; +const MAX_MODELS_PER_GROUP = 5; -/** 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; @@ -45,18 +40,20 @@ interface MultiRunAttachedFile { dataUrl: string; } +interface RunGroupState { + id: string; + templateId: string; + prompt: string; + models: ModelSelectionWithId[]; +} + 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 }) => ( @@ -70,7 +67,6 @@ const InfoTip: React.FC<{ children: React.ReactNode }> = ({ children }) => ( ); -/** Compact field label */ const FieldLabel: React.FC<{ htmlFor?: string; required?: boolean; @@ -86,10 +82,6 @@ const FieldLabel: React.FC<{ ); -/** - * Launcher form for creating a new Multi-Run group. - * Compact, centered card layout with adaptive grid. - */ export const MultiRunLauncher: React.FC = ({ initialPrompt, onCreated, @@ -98,8 +90,9 @@ export const MultiRunLauncher: React.FC = ({ }) => { const { t } = useI18n(); const [name, setName] = React.useState(''); - const [prompt, setPrompt] = React.useState(() => initialPrompt ?? ''); - const [selectedModels, setSelectedModels] = 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); @@ -107,27 +100,23 @@ export const MultiRunLauncher: React.FC = ({ const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false); const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false); const [isolateRuns, setIsolateRuns] = React.useState(true); - 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 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; - } + 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); @@ -144,9 +133,7 @@ export const MultiRunLauncher: React.FC = ({ }, [activeProjectId, projects, selectedProjectId]); const selectedProject = React.useMemo(() => { - if (!selectedProjectId) { - return null; - } + if (!selectedProjectId) return null; return projects.find((project) => project.id === selectedProjectId) ?? null; }, [projects, selectedProjectId]); @@ -196,60 +183,29 @@ export const MultiRunLauncher: React.FC = ({ if (selectedProject?.path) { return { id: selectedProject.id, path: selectedProject.path }; } - const base = currentDirectory ?? vscodeWorkspaceFolder; - if (!base) { - return null; - } - + 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 [isDesktopApp] = React.useState(() => (typeof window !== 'undefined' ? isDesktopShell() : false)); const isMacPlatform = React.useMemo(() => { - if (typeof navigator === 'undefined') { - return false; - } + if (typeof navigator === 'undefined') return false; return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - setIsDesktopApp(isDesktopShell()); - }, []); - const macosMajorVersion = React.useMemo(() => { - if (typeof window === 'undefined') { - return null; - } - + 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; - } + 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; - } + if (!match) return null; const first = Number.parseInt(match[1], 10); const second = Number.parseInt(match[2], 10); - if (Number.isNaN(first)) { - return null; - } + if (Number.isNaN(first)) return null; return first === 10 ? second : first; }, []); @@ -262,31 +218,18 @@ export const MultiRunLauncher: React.FC = ({ }, [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'; - } + 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(); - } + 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) => { @@ -300,7 +243,6 @@ export const MultiRunLauncher: React.FC = ({ 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 wasIsolationDisabledByNonGitRef = React.useRef(false); @@ -326,56 +268,56 @@ export const MultiRunLauncher: React.FC = ({ React.useEffect(() => { if (typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) { - setPrompt((prev) => (prev.trim().length > 0 ? prev : initialPrompt)); + setRunGroups((prev) => { + const updated = [...prev]; + if (updated.length > 0 && !updated[0].prompt.trim()) { + updated[0] = { ...updated[0], prompt: initialPrompt }; + } + return updated; + }); } }, [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); - } + if (!cancelled) setSetupCommands(commands); } catch { - // Ignore errors, start with empty commands + // Ignore } finally { - if (!cancelled) { - setIsLoadingSetupCommands(false); - } + 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 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]; @@ -383,7 +325,6 @@ export const MultiRunLauncher: React.FC = ({ toast.error(t('multirun.launcher.toast.fileTooLarge', { fileName: file.name })); continue; } - try { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); @@ -391,172 +332,44 @@ export const MultiRunLauncher: React.FC = ({ reader.onerror = reject; reader.readAsDataURL(file); }); - - const newFile: MultiRunAttachedFile = { + setAttachedFiles((prev) => [...prev, { 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); + } 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 }) + : t('multirun.launcher.toast.attachedPlural', { count: attachedCount }), ); } - - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + 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 totalRunCount = React.useMemo( + () => runGroups.reduce((sum, g) => sum + g.models.length, 0), + [runGroups], + ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!prompt.trim()) { - return; - } - if (selectedModels.length < 2) { - return; - } + const validGroups = runGroups.filter((g) => g.prompt.trim() && g.models.length >= 1); + if (validGroups.length === 0) return; + if (totalRunCount < 1) return; setIsSubmitting(true); clearError(); @@ -566,24 +379,23 @@ export const MultiRunLauncher: React.FC = ({ 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 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(), - prompt: prompt.trim(), - models: modelsForStore, + groups, agent: selectedAgent || undefined, worktreeBaseBranch: effectiveIsolateRuns ? worktreeBaseBranch : undefined, isolateRuns: effectiveIsolateRuns, @@ -592,29 +404,27 @@ export const MultiRunLauncher: React.FC = ({ }; const result = await createMultiRun(params); - if (result) { - if (result.firstSessionId) { - useSessionUIStore.getState().setCurrentSession(result.firstSessionId); - } - - // Close launcher - onCreated?.(); - } + if (result) { + if (result.firstSessionId) { + useSessionUIStore.getState().setCurrentSession(result.firstSessionId); + } + onCreated?.(); + } } finally { setIsSubmitting(false); } }; const isValid = Boolean( - name.trim() && - prompt.trim() && - selectedModels.length >= 2 && - selectedProjectDirectory && - !isLoadingWorktreeBaseBranches && - (isGitRepository === false || !effectiveIsolateRuns || worktreeBaseBranch) + 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; + const configuredSetupCount = setupCommands.filter((cmd) => cmd.trim()).length; return (
@@ -642,30 +452,22 @@ export const MultiRunLauncher: React.FC = ({ - -

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

-
+

{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 ? ( - {selectedProject ? ( {renderProjectLabel(selectedProject)} @@ -686,13 +488,8 @@ export const MultiRunLauncher: React.FC = ({ )}
- {/* Group name */}
- {t('multirun.launcher.groupName.info')}} - > + {t('multirun.launcher.groupName.info')}}> {t('multirun.launcher.groupName.label')} = ({ />
- {/* Base branch */}
- {t('multirun.launcher.baseBranch.info')}} - > + {t('multirun.launcher.baseBranch.info')}}> {t('multirun.launcher.baseBranch.label')} = ({
- {/* Agent */}
- {t('multirun.launcher.agent.info')}} - > + {t('multirun.launcher.agent.info')}}> {t('multirun.launcher.agent.label')} - +
- {/* ── Setup commands (collapsible, full width) ── */} @@ -769,7 +553,7 @@ export const MultiRunLauncher: React.FC = ({ )} @@ -792,10 +576,7 @@ export const MultiRunLauncher: React.FC = ({ />