diff --git a/CHANGELOG.md b/CHANGELOG.md index 76461e50..a58bced3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- VS Code added Agent Manager, run the same promt in parallel with up to 5 models. +- Change in Branch and Session Naming for Multi Run, /// + ## [1.4.2] - 2026-01-02 - Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt). diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b1d0d965..f6d60585 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; +import { AgentManagerView } from '@/components/views/agent-manager'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; @@ -191,6 +192,24 @@ function App({ apis }: AppProps) { // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { + // Check if this is the Agent Manager panel + const panelType = typeof window !== 'undefined' + ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ + : 'chat'; + + if (panelType === 'agentManager') { + return ( + + +
+ + +
+
+
+ ); + } + return ( diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index b2966aee..ddef8956 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -5,7 +5,7 @@ import { ChatView, SettingsView } from '@/components/views'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; -import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react'; +import { RiAddLine, RiArrowLeftLine, RiRobot2Line, RiSettings3Line } from '@remixicon/react'; // Width threshold for mobile vs desktop layout in settings const MOBILE_WIDTH_THRESHOLD = 550; @@ -195,10 +195,11 @@ interface VSCodeHeaderProps { onBack?: () => void; onNewSession?: () => void; onSettings?: () => void; + onAgentManager?: () => void; showContextUsage?: boolean; } -const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => { +const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showContextUsage }) => { const { getCurrentModel } = useConfigStore(); const getContextUsage = useSessionStore((state) => state.getContextUsage); @@ -231,6 +232,15 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on )} + {onAgentManager && ( + + )} {onSettings && ( + + ); +}; + +export interface ModelMultiSelectProps { + selectedModels: ModelSelectionWithId[]; + onAdd: (model: ModelSelectionWithId) => void; + onRemove: (index: number) => void; + /** Minimum models required (shows validation hint) */ + minModels?: number; + /** Label for the add button */ + addButtonLabel?: string; + /** Whether to show the selected chips inline */ + showChips?: boolean; + /** Maximum models allowed */ + maxModels?: number; +} + +/** + * Model selector for multi-run (allows selecting same model multiple times). + */ +export const ModelMultiSelect: React.FC = ({ + selectedModels, + onAdd, + onRemove, + minModels, + addButtonLabel = 'Add model', + showChips = true, + maxModels, +}) => { + const { providers, modelsMetadata } = useConfigStore(); + const { favoriteModelsList, recentModelsList } = useModelLists(); + const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const searchInputRef = React.useRef(null); + const dropdownRef = React.useRef(null); + const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]); + + // Count occurrences of each model for display purposes + const modelCounts = React.useMemo(() => { + const counts = new Map(); + for (const m of selectedModels) { + const key = `${m.providerID}:${m.modelID}`; + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; + }, [selectedModels]); + + // Get instance index for a specific model selection + const getInstanceIndex = React.useCallback((model: ModelSelectionWithId): number => { + const sameModels = selectedModels.filter( + m => m.providerID === model.providerID && m.modelID === model.modelID + ); + return sameModels.findIndex(m => m.instanceId === model.instanceId) + 1; + }, [selectedModels]); + + const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { + const key = `${provId}/${modId}`; + return modelsMetadata.get(key); + }; + + const getModelDisplayName = (model: Record) => { + const name = model?.name || model?.id || ''; + const nameStr = String(name); + if (nameStr.length > 40) { + return nameStr.substring(0, 37) + '...'; + } + return nameStr; + }; + + // Filter helper + const filterByQuery = React.useCallback((modelName: string, providerName: string) => { + if (!searchQuery.trim()) return true; + const lowerQuery = searchQuery.toLowerCase(); + return ( + modelName.toLowerCase().includes(lowerQuery) || + providerName.toLowerCase().includes(lowerQuery) + ); + }, [searchQuery]); + + // Filter favorites + const filteredFavorites = React.useMemo(() => { + return favoriteModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + }, [favoriteModelsList, providers, filterByQuery]); + + // Filter recents + const filteredRecents = React.useMemo(() => { + return recentModelsList.filter(({ model, providerID }) => { + const provider = providers.find(p => p.id === providerID); + const providerName = provider?.name || providerID; + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, providerName); + }); + }, [recentModelsList, providers, filterByQuery]); + + // Filter providers + const filteredProviders = React.useMemo(() => { + return providers + .map((provider) => { + const models = Array.isArray(provider.models) ? provider.models : []; + const filteredModels = models.filter((model) => { + const modelName = getModelDisplayName(model); + return filterByQuery(modelName, provider.name || provider.id || ''); + }); + return { ...provider, models: filteredModels }; + }) + .filter((provider) => provider.models.length > 0); + }, [providers, filterByQuery]); + + const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; + + // 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(''); + setSelectedIndex(0); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Reset selection when search query changes + React.useEffect(() => { + setSelectedIndex(0); + }, [searchQuery]); + + // Render a model row + const renderModelRow = ( + model: Record, + providerID: string, + modelID: string, + keyPrefix: string, + flatIndex: number, + isHighlighted: boolean + ) => { + const key = `${providerID}:${modelID}`; + const selectionCount = modelCounts.get(key) || 0; + const metadata = getModelMetadata(providerID, modelID); + const contextTokens = formatTokens(metadata?.limit?.context); + + return ( + + ); + }; + + return ( +
+
+ {/* Add model button (dropdown trigger) */} +
+ + + {isOpen && (() => { + // Build flat list for keyboard navigation + type FlatModelItem = { model: Record; providerID: string; modelID: string; section: string }; + const flatModelList: FlatModelItem[] = []; + + filteredFavorites.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'fav' }); + }); + filteredRecents.forEach(({ model, providerID, modelID }) => { + flatModelList.push({ model, providerID, modelID, section: 'recent' }); + }); + filteredProviders.forEach((provider) => { + provider.models.forEach((model) => { + flatModelList.push({ model, providerID: provider.id, modelID: model.id as string, section: 'provider' }); + }); + }); + + const totalItems = flatModelList.length; + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); + setSelectedIndex(nextIndex); + setTimeout(() => { + itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); + setSelectedIndex(prevIndex); + setTimeout(() => { + itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 0); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + const selectedItem = flatModelList[selectedIndex]; + if (selectedItem) { + onAdd({ + providerID: selectedItem.providerID, + modelID: selectedItem.modelID, + displayName: (selectedItem.model.name as string) || selectedItem.modelID, + instanceId: generateInstanceId(), + }); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setIsOpen(false); + setSearchQuery(''); + setSelectedIndex(0); + } + }; + + let currentFlatIndex = 0; + + return ( +
+ {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 pl-8 typography-meta" + /> +
+
+ + {/* Models list */} + +
+ {!hasResults && ( +
+ No models found +
+ )} + + {/* Favorites Section */} + {filteredFavorites.length > 0 && ( + <> +
+ + Favorites +
+ {filteredFavorites.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); + })} + + )} + + {/* Recents Section */} + {filteredRecents.length > 0 && ( + <> + {filteredFavorites.length > 0 &&
} +
+ + Recent +
+ {filteredRecents.map(({ model, providerID, modelID }) => { + const idx = currentFlatIndex++; + return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); + })} + + )} + + {/* Separator before providers */} + {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( +
+ )} + + {/* All Providers - Flat List */} + {filteredProviders.map((provider, index) => ( + + {index > 0 &&
} +
+ + {provider.name} +
+ {provider.models.map((model) => { + const idx = currentFlatIndex++; + return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx); + })} + + ))} +
+ + + {/* Keyboard hints footer */} +
+ ↑↓ navigate • Enter select • Esc close +
+
+ ); + })()} +
+ + {/* Selected models */} + {showChips && selectedModels.map((model, index) => { + const key = `${model.providerID}:${model.modelID}`; + const totalSameModel = modelCounts.get(key) || 1; + const instanceIndex = getInstanceIndex(model); + return ( + onRemove(index)} + /> + ); + })} +
+ + {/* Validation hint */} + {minModels !== undefined && selectedModels.length < minModels && ( +

+ Select at least {minModels} model{minModels > 1 ? 's' : ''} {maxModels !== undefined ? `and at most ${maxModels} models` : ''}. +

+ )} +
+ ); +}; diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 961ab393..d08a88a9 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -1,32 +1,18 @@ import React from 'react'; -import { RiAddLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react'; +import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react'; import { toast } from 'sonner'; 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 { useUIStore } from '@/stores/useUIStore'; -import { useModelLists } from '@/hooks/useModelLists'; import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun'; -import type { ModelMetadata } from '@/types'; +import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect'; +import { BranchSelector, useBranchOptions } from './BranchSelector'; /** Max file size in bytes (10MB) */ const MAX_FILE_SIZE = 10 * 1024 * 1024; @@ -43,13 +29,6 @@ interface MultiRunAttachedFile { dataUrl: string; } -/** UI-only type with instanceId for React keys and duplicate tracking */ -type ModelSelectionWithId = MultiRunModelSelection & { instanceId: string }; - -const generateInstanceId = (): string => { - return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -}; - interface MultiRunLauncherProps { /** Prefill prompt textarea (optional) */ initialPrompt?: string; @@ -59,425 +38,6 @@ interface MultiRunLauncherProps { 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. - * Shows instance index (e.g., "(2)") when same model is selected multiple times. - */ -const ModelChip: React.FC<{ - model: ModelSelectionWithId; - instanceIndex: number; - totalSameModel: number; - onRemove: () => void; -}> = ({ model, instanceIndex, totalSameModel, onRemove }) => { - const displayName = model.displayName || `${model.providerID}/${model.modelID}`; - const label = totalSameModel > 1 ? `${displayName} (${instanceIndex})` : displayName; - - return ( -
- - - {label} - - -
- ); -}; - -const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { - notation: 'compact', - compactDisplay: 'short', - maximumFractionDigits: 1, - minimumFractionDigits: 0, -}); - -const formatTokens = (value?: number | null) => { - if (typeof value !== 'number' || Number.isNaN(value)) { - return ''; - } - if (value === 0) { - return '0'; - } - const formatted = COMPACT_NUMBER_FORMATTER.format(value); - return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; -}; - -/** - * Model selector for multi-run (allows selecting same model multiple times). - */ -const ModelMultiSelect: React.FC<{ - selectedModels: ModelSelectionWithId[]; - onAdd: (model: ModelSelectionWithId) => void; - onRemove: (index: number) => void; -}> = ({ selectedModels, onAdd, onRemove }) => { - const { providers, modelsMetadata } = useConfigStore(); - const { favoriteModelsList, recentModelsList } = useModelLists(); - const [isOpen, setIsOpen] = React.useState(false); - const [searchQuery, setSearchQuery] = React.useState(''); - const [selectedIndex, setSelectedIndex] = React.useState(0); - const searchInputRef = React.useRef(null); - const dropdownRef = React.useRef(null); - const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]); - - // Count occurrences of each model for display purposes - const modelCounts = React.useMemo(() => { - const counts = new Map(); - for (const m of selectedModels) { - const key = `${m.providerID}:${m.modelID}`; - counts.set(key, (counts.get(key) || 0) + 1); - } - return counts; - }, [selectedModels]); - - // Get instance index for a specific model selection - const getInstanceIndex = React.useCallback((model: ModelSelectionWithId): number => { - const sameModels = selectedModels.filter( - m => m.providerID === model.providerID && m.modelID === model.modelID - ); - return sameModels.findIndex(m => m.instanceId === model.instanceId) + 1; - }, [selectedModels]); - - const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { - const key = `${provId}/${modId}`; - return modelsMetadata.get(key); - }; - - const getModelDisplayName = (model: Record) => { - const name = model?.name || model?.id || ''; - const nameStr = String(name); - if (nameStr.length > 40) { - return nameStr.substring(0, 37) + '...'; - } - return nameStr; - }; - - // Filter helper - const filterByQuery = React.useCallback((modelName: string, providerName: string) => { - if (!searchQuery.trim()) return true; - const lowerQuery = searchQuery.toLowerCase(); - return ( - modelName.toLowerCase().includes(lowerQuery) || - providerName.toLowerCase().includes(lowerQuery) - ); - }, [searchQuery]); - - // Filter favorites - const filteredFavorites = React.useMemo(() => { - return favoriteModelsList.filter(({ model, providerID }) => { - const provider = providers.find(p => p.id === providerID); - const providerName = provider?.name || providerID; - const modelName = getModelDisplayName(model); - return filterByQuery(modelName, providerName); - }); - }, [favoriteModelsList, providers, filterByQuery]); - - // Filter recents - const filteredRecents = React.useMemo(() => { - return recentModelsList.filter(({ model, providerID }) => { - const provider = providers.find(p => p.id === providerID); - const providerName = provider?.name || providerID; - const modelName = getModelDisplayName(model); - return filterByQuery(modelName, providerName); - }); - }, [recentModelsList, providers, filterByQuery]); - - // Filter providers - const filteredProviders = React.useMemo(() => { - return providers - .map((provider) => { - const models = Array.isArray(provider.models) ? provider.models : []; - const filteredModels = models.filter((model) => { - const modelName = getModelDisplayName(model); - return filterByQuery(modelName, provider.name || provider.id || ''); - }); - return { ...provider, models: filteredModels }; - }) - .filter((provider) => provider.models.length > 0); - }, [providers, filterByQuery]); - - const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; - - // 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(''); - setSelectedIndex(0); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen]); - - // Reset selection when search query changes - React.useEffect(() => { - setSelectedIndex(0); - }, [searchQuery]); - - // Render a model row - const renderModelRow = ( - model: Record, - providerID: string, - modelID: string, - keyPrefix: string, - flatIndex: number, - isHighlighted: boolean - ) => { - const key = `${providerID}:${modelID}`; - const selectionCount = modelCounts.get(key) || 0; - const metadata = getModelMetadata(providerID, modelID); - const contextTokens = formatTokens(metadata?.limit?.context); - - return ( - - ); - }; - - return ( -
-
- {/* Add model button (dropdown trigger) */} -
- - - {isOpen && (() => { - // Build flat list for keyboard navigation - type FlatModelItem = { model: Record; providerID: string; modelID: string; section: string }; - const flatModelList: FlatModelItem[] = []; - - filteredFavorites.forEach(({ model, providerID, modelID }) => { - flatModelList.push({ model, providerID, modelID, section: 'fav' }); - }); - filteredRecents.forEach(({ model, providerID, modelID }) => { - flatModelList.push({ model, providerID, modelID, section: 'recent' }); - }); - filteredProviders.forEach((provider) => { - provider.models.forEach((model) => { - flatModelList.push({ model, providerID: provider.id, modelID: model.id as string, section: 'provider' }); - }); - }); - - const totalItems = flatModelList.length; - - // Handle keyboard navigation - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - e.stopPropagation(); - const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); - setSelectedIndex(nextIndex); - setTimeout(() => { - itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, 0); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - e.stopPropagation(); - const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); - setSelectedIndex(prevIndex); - setTimeout(() => { - itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, 0); - } else if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - const selectedItem = flatModelList[selectedIndex]; - if (selectedItem) { - onAdd({ - providerID: selectedItem.providerID, - modelID: selectedItem.modelID, - displayName: (selectedItem.model.name as string) || selectedItem.modelID, - instanceId: generateInstanceId(), - }); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - setIsOpen(false); - setSearchQuery(''); - setSelectedIndex(0); - } - }; - - let currentFlatIndex = 0; - - return ( -
- {/* Search input */} -
-
- - setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - className="h-8 pl-8 typography-meta" - /> -
-
- - {/* Models list */} - -
- {!hasResults && ( -
- No models found -
- )} - - {/* Favorites Section */} - {filteredFavorites.length > 0 && ( - <> -
- - Favorites -
- {filteredFavorites.map(({ model, providerID, modelID }) => { - const idx = currentFlatIndex++; - return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); - })} - - )} - - {/* Recents Section */} - {filteredRecents.length > 0 && ( - <> - {filteredFavorites.length > 0 &&
} -
- - Recent -
- {filteredRecents.map(({ model, providerID, modelID }) => { - const idx = currentFlatIndex++; - return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); - })} - - )} - - {/* Separator before providers */} - {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( -
- )} - - {/* All Providers - Flat List */} - {filteredProviders.map((provider, index) => ( - - {index > 0 &&
} -
- - {provider.name} -
- {provider.models.map((model) => { - const idx = currentFlatIndex++; - return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx); - })} - - ))} -
- - - {/* Keyboard hints footer */} -
- ↑↓ navigate • Enter select • Esc close -
-
- ); - })()} -
- - {/* Selected models */} - {selectedModels.map((model, index) => { - const key = `${model.providerID}:${model.modelID}`; - const totalSameModel = modelCounts.get(key) || 1; - const instanceIndex = getInstanceIndex(model); - return ( - onRemove(index)} - /> - ); - })} -
-
- ); -}; - /** * Launcher form for creating a new Multi-Run group. * Replaces the main content area (tabs) with a form. @@ -526,12 +86,9 @@ export const MultiRunLauncher: React.FC = ({ return 'pl-3'; }, [isDesktopApp, isMacPlatform, isSidebarOpen]); + // Use the BranchSelector hook for branch state management 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 { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory); const createMultiRun = useMultiRunStore((state) => state.createMultiRun); const error = useMultiRunStore((state) => state.error); @@ -543,74 +100,6 @@ export const MultiRunLauncher: React.FC = ({ } }, [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: ModelSelectionWithId) => { if (selectedModels.length >= MAX_MODELS) { return; @@ -683,7 +172,6 @@ export const MultiRunLauncher: React.FC = ({ return; } - setIsSubmitting(true); clearError(); @@ -802,72 +290,16 @@ export const MultiRunLauncher: React.FC = ({ > Base branch - + onChange={setWorktreeBaseBranch} + id="multirun-worktree-base-branch" + />

Creates new branches from{' '} {worktreeBaseBranch || 'HEAD'}.

- {isGitRepository === false ? ( -

Not in a git repository.

- ) : null}
@@ -948,12 +380,13 @@ export const MultiRunLauncher: React.FC = ({
diff --git a/packages/ui/src/components/multirun/index.ts b/packages/ui/src/components/multirun/index.ts index 9a7cb7c1..1a1511be 100644 --- a/packages/ui/src/components/multirun/index.ts +++ b/packages/ui/src/components/multirun/index.ts @@ -1 +1,3 @@ export { MultiRunLauncher } from './MultiRunLauncher'; +export { ModelMultiSelect, ModelChip, generateInstanceId, type ModelSelectionWithId, type ModelSelection, type ModelMultiSelectProps } from './ModelMultiSelect'; +export { BranchSelector, useBranchOptions, type BranchSelectorProps, type BranchSelectorState, type WorktreeBaseOption } from './BranchSelector'; diff --git a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx new file mode 100644 index 00000000..93e019e4 --- /dev/null +++ b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { + RiGitBranchLine, + RiArrowDownSLine, + RiCheckLine, +} from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { ChatContainer } from '@/components/chat/ChatContainer'; +import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface AgentGroupDetailProps { + group: AgentGroup; + className?: string; +} + +export const AgentGroupDetail: React.FC = ({ + group, + className, +}) => { + const { selectedSessionId, selectSession } = useAgentGroupsStore(); + const { setCurrentSession, currentSessionId } = useSessionStore(); + + // Find the currently selected session + const selectedSession = React.useMemo(() => { + if (!selectedSessionId) return group.sessions[0] ?? null; + return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null; + }, [group.sessions, selectedSessionId]); + + // When selecting a session, switch to that OpenCode session + // NOTE: We intentionally do NOT change the global directory here to avoid + // re-triggering loadGroups() which would cause groups to disappear + const handleSessionSelect = React.useCallback((session: AgentGroupSession) => { + selectSession(session.id); + + // Switch to the OpenCode session + setCurrentSession(session.id); + }, [selectSession, setCurrentSession]); + + // Auto-select first session when group changes and sync OpenCode session + React.useEffect(() => { + if (group.sessions.length > 0) { + const session = selectedSessionId + ? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] + : group.sessions[0]; + + if (session) { + // Always ensure the OpenCode session is synced + if (session.id !== currentSessionId) { + setCurrentSession(session.id); + } + + // Update selection if not already selected + if (!selectedSessionId) { + selectSession(session.id); + } + } + } + }, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]); + + // Check if the current OpenCode session matches the selected agent group session + const isSessionSynced = selectedSession?.id === currentSessionId; + + return ( +
+ {/* Header */} +
+
+
+

{group.name}

+
+ {group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''} + · + + + {selectedSession?.worktreeMetadata?.label || selectedSession?.branch || 'No branch'} + +
+
+
+ + {/* Model Selector Dropdown */} + {group.sessions.length > 0 && ( +
+ + + + + + {group.sessions.map((session) => ( + handleSessionSelect(session)} + className="flex items-center gap-2 py-2" + > + +
+
+ + {session.modelId} + + {session.instanceNumber > 1 && ( + + #{session.instanceNumber} + + )} +
+ {session.branch && ( +
+ + {session.worktreeMetadata?.label || session.branch} +
+ )} +
+ {selectedSession?.id === session.id && ( + + )} +
+ ))} +
+
+
+ )} +
+ + {/* Chat Content */} +
+ {selectedSession ? ( + isSessionSynced ? ( + + + + ) : ( +
+ {/* Info banner about the worktree */} +
+
+ + + {selectedSession.displayLabel} + + · + + {selectedSession.path} + +
+
+ + {/* Loading or no session state */} +
+
+

+ Loading session for {selectedSession.displayLabel} +

+

+ Session ID: {selectedSession.id} +

+
+
+
+ ) + ) : ( +
+

+ No sessions in this group +

+
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx new file mode 100644 index 00000000..4c6bc459 --- /dev/null +++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import { + RiAddCircleLine, + RiCloseLine, + RiFileImageLine, + RiFileLine, + RiGitBranchLine, + RiHourglassFill, + RiSendPlane2Line, +} from '@remixicon/react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect'; +import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector'; +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) => 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 [baseBranch, setBaseBranch] = React.useState('HEAD'); + const [attachedFiles, setAttachedFiles] = React.useState([]); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const fileInputRef = React.useRef(null); + const textareaRef = React.useRef(null); + + const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); + const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory); + + 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 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 }) => ({ + providerID, + modelID, + displayName, + })); + + const files: MultiRunFileAttachment[] | undefined = attachedFiles.length > 0 + ? attachedFiles.map((f) => ({ + mime: f.mimeType, + filename: f.filename, + url: f.dataUrl, + })) + : undefined; + + onCreateGroup?.({ + name: groupName.trim(), + prompt: prompt.trim(), + models, + worktreeBaseBranch: baseBranch, + files, + }); + + // Reset form on success + setGroupName(''); + setPrompt(''); + setSelectedModels([]); + setAttachedFiles([]); + setBaseBranch('HEAD'); + } catch (error) { + console.error('Failed to create agent group:', error); + toast.error('Failed to create agent group'); + } finally { + setIsSubmitting(false); + } + }; + + 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} +

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