diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 486eb720..933af24b 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -17,11 +17,15 @@ import { FadeInOnReveal } from './FadeInOnReveal'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine } from '@remixicon/react'; +import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { flattenAssistantTextParts } from '@/lib/messages/messageText'; +import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta'; const useMigrationTimer = ( @@ -350,6 +354,7 @@ const AssistantMessageBody: React.FC> = ({ }, [visibleParts]); const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); + const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; @@ -543,6 +548,22 @@ const AssistantMessageBody: React.FC> = ({ [createSessionFromAssistantMessage, messageId] ); + const handleForkMultiRunClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + const assistantPlanText = flattenAssistantTextParts(assistantTextParts); + if (!assistantPlanText.trim()) { + return; + } + + const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`; + openMultiRunLauncherWithPrompt(prefilledPrompt); + }, + [assistantTextParts, openMultiRunLauncherWithPrompt] + ); + React.useEffect(() => { return () => { clearCopyHintTimeout(); @@ -1060,21 +1081,37 @@ const AssistantMessageBody: React.FC> = ({ const footerButtons = ( <> - - - - - Start new session from this answer - + + + + + Start new session from this answer + + + + + + Start new multi-run from this answer + + {onCopyMessage && ( diff --git a/packages/ui/src/components/icons/ArrowsMerge.tsx b/packages/ui/src/components/icons/ArrowsMerge.tsx new file mode 100644 index 00000000..2bf971c9 --- /dev/null +++ b/packages/ui/src/components/icons/ArrowsMerge.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export function ArrowsMerge(props: SVGProps) { + return ( + + + + ); +} diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 313d3bd1..f5471418 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -8,6 +8,7 @@ import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; +import { MultiRunLauncher } from '@/components/multirun'; import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; @@ -26,7 +27,11 @@ export const MainLayout: React.FC = () => { setSessionSwitcherOpen, isSettingsDialogOpen, setSettingsDialogOpen, + isMultiRunLauncherOpen, + setMultiRunLauncherOpen, + multiRunLauncherPrefillPrompt, } = useUIStore(); + const { isMobile } = useDeviceInfo(); const [isDesktopRuntime, setIsDesktopRuntime] = React.useState(() => { if (typeof window === 'undefined') { @@ -268,12 +273,12 @@ if (measuredInset === 0) { {isMobile ? ( <> - {/* Mobile: Header + content + settings overlay */} - {!isSettingsDialogOpen &&
} + {/* Mobile: Header + content + overlays */} + {!(isSettingsDialogOpen || isMultiRunLauncherOpen) &&
}
@@ -297,6 +302,19 @@ if (measuredInset === 0) { + {/* Mobile multi-run launcher: full screen */} + {isMultiRunLauncherOpen && ( +
+ + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> + +
+ )} + {/* Mobile settings: full screen */} {isSettingsDialogOpen && (
@@ -315,7 +333,7 @@ if (measuredInset === 0) { {/* Main content area */}
{/* Normal view: Header + content */} -
+
@@ -330,6 +348,19 @@ if (measuredInset === 0) {
+ + {/* Multi-Run Launcher: replaces tabs content only */} + {isMultiRunLauncherOpen && ( +
+ + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> + +
+ )}
{/* Settings view: full screen overlay */} diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx new file mode 100644 index 00000000..47be30ee --- /dev/null +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -0,0 +1,592 @@ +import React from 'react'; +import { RiAddLine, RiCloseLine, RiPlayLine, RiSearchLine } 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 type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; + +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}`} + + +
+ ); +}; + +/** + * 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 = useConfigStore((state) => state.providers); + const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + const searchInputRef = React.useRef(null); + const dropdownRef = React.useRef(null); + + // Get set of already selected model keys + const selectedKeys = React.useMemo(() => { + return new Set(selectedModels.map((m) => `${m.providerID}:${m.modelID}`)); + }, [selectedModels]); + + // Filter models based on search query + const filteredProviders = React.useMemo(() => { + if (!searchQuery.trim()) return providers; + + const query = searchQuery.toLowerCase(); + return providers + .map((provider) => { + const models = Array.isArray(provider.models) ? provider.models : []; + const filteredModels = models.filter((model) => { + const modelName = (model.name || model.id || '').toString().toLowerCase(); + const providerName = provider.name.toLowerCase(); + return modelName.includes(query) || providerName.includes(query); + }); + return { ...provider, models: filteredModels }; + }) + .filter((provider) => provider.models.length > 0); + }, [providers, searchQuery]); + + // 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(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + return ( +
+
+ {/* Add model button (dropdown trigger) */} +
+ + + {isOpen && ( +
+ {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 typography-meta" + /> +
+
+ + {/* Models list */} + + {filteredProviders.length === 0 ? ( +
+ No models found +
+ ) : ( + filteredProviders.map((provider) => { + const models = Array.isArray(provider.models) ? provider.models : []; + if (models.length === 0) return null; + + return ( +
+
+ + + {provider.name} + +
+ {models.map((model) => { + const key = `${provider.id}:${model.id}`; + const isSelected = selectedKeys.has(key); + + return ( + + ); + })} +
+ ); + }) + )} +
+
+ )} +
+ + {/* 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 [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" + 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 */} +
+ +