feat(vscode) Agent Manager (#87)
* Add Agent Manager * Basic Mock UP * Fix Comand naming Ctrl+P Uses Category to group * Move the UI in views * Agent Manager Landing Page * Fix attachment buig * Change Session Name for multi run to incoporate groupSlug * First running UI * Fix Max Model Multi Run * Rework Agent Group detection * ignore false positives with ' ' in it * Simplify Logic * remove unused dropdowns * Clean up * Update Changelog
This commit is contained in:
@@ -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<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => {
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showContextUsage }) => {
|
||||
const { getCurrentModel } = useConfigStore();
|
||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
||||
|
||||
@@ -231,6 +232,15 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{onAgentManager && (
|
||||
<button
|
||||
onClick={onAgentManager}
|
||||
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label="Open Agent Manager"
|
||||
>
|
||||
<RiRobot2Line className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{onSettings && (
|
||||
<button
|
||||
onClick={onSettings}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
|
||||
export type WorktreeBaseOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'special' | 'local' | 'remote';
|
||||
};
|
||||
|
||||
export interface BranchSelectorProps {
|
||||
/** Current directory to check for git repository */
|
||||
directory: string | null;
|
||||
/** Currently selected branch */
|
||||
value: string;
|
||||
/** Called when branch selection changes */
|
||||
onChange: (branch: string) => void;
|
||||
/** Optional className for the trigger */
|
||||
className?: string;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
/** ID for accessibility */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface BranchSelectorState {
|
||||
branches: WorktreeBaseOption[];
|
||||
isLoading: boolean;
|
||||
isGitRepository: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to load available git branches for a directory.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- Hook is tightly coupled with BranchSelector
|
||||
export function useBranchOptions(directory: string | null): BranchSelectorState {
|
||||
const [branches, setBranches] = React.useState<WorktreeBaseOption[]>([
|
||||
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
|
||||
]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!directory) {
|
||||
setIsGitRepository(null);
|
||||
setIsLoading(false);
|
||||
setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsGitRepository(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const isGit = await checkIsGitRepository(directory);
|
||||
if (cancelled) return;
|
||||
|
||||
setIsGitRepository(isGit);
|
||||
|
||||
if (!isGit) {
|
||||
setBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
const branchData = await getGitBranches(directory).catch(() => null);
|
||||
if (cancelled) return;
|
||||
|
||||
const worktreeBaseOptions: WorktreeBaseOption[] = [];
|
||||
const headLabel = branchData?.current ? `Current (HEAD: ${branchData.current})` : 'Current (HEAD)';
|
||||
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||
|
||||
if (branchData) {
|
||||
const localBranches = branchData.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 = branchData.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' });
|
||||
});
|
||||
}
|
||||
|
||||
setBranches(worktreeBaseOptions);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [directory]);
|
||||
|
||||
return { branches, isLoading, isGitRepository };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch selector dropdown for selecting a base branch for worktree creation.
|
||||
*/
|
||||
export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
directory,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
}) => {
|
||||
const { branches, isLoading, isGitRepository } = useBranchOptions(directory);
|
||||
|
||||
// Update value if it's no longer valid
|
||||
React.useEffect(() => {
|
||||
const isValid = branches.some((option) => option.value === value);
|
||||
if (!isValid && branches.length > 0) {
|
||||
onChange('HEAD');
|
||||
}
|
||||
}, [branches, value, onChange]);
|
||||
|
||||
const isDisabled = disabled || !isGitRepository || isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
size="lg"
|
||||
className={className ?? 'max-w-full typography-meta text-foreground'}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={isLoading ? 'Loading branches…' : 'Select a branch'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{branches
|
||||
.filter((option) => option.group === 'special')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
{branches.some((option) => option.group === 'local') ? (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{branches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{branches.some((option) => option.group === 'remote') ? (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{branches
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isGitRepository === false && (
|
||||
<p className="typography-micro text-muted-foreground/70">Not in a git repository.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiCloseLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
|
||||
/** Chip height class - shared between chips and add button */
|
||||
const CHIP_HEIGHT_CLASS = 'h-7';
|
||||
|
||||
/** UI-only type with instanceId for React keys and duplicate tracking */
|
||||
export interface ModelSelectionWithId {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
displayName?: string;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/** Model selection without instanceId (for external use) */
|
||||
export interface ModelSelection {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect
|
||||
export const generateInstanceId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
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 selection chip with remove button.
|
||||
* Shows instance index (e.g., "(2)") when same model is selected multiple times.
|
||||
*/
|
||||
export 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 (
|
||||
<div className={cn('flex items-center gap-1.5 px-2 rounded-md bg-accent/50 border border-border/30', CHIP_HEIGHT_CLASS)}>
|
||||
<ProviderLogo providerId={model.providerID} className="h-3.5 w-3.5" />
|
||||
<span className="typography-meta font-medium truncate max-w-[140px]">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-foreground ml-0.5"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<ModelMultiSelectProps> = ({
|
||||
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<HTMLInputElement>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Count occurrences of each model for display purposes
|
||||
const modelCounts = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>,
|
||||
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 (
|
||||
<button
|
||||
key={`${keyPrefix}-${key}`}
|
||||
ref={(el) => { itemRefs.current[flatIndex] = el; }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd({
|
||||
providerID,
|
||||
modelID,
|
||||
displayName: (model.name as string) || modelID,
|
||||
instanceId: generateInstanceId(),
|
||||
});
|
||||
// Don't close dropdown - allow selecting multiple
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded-md typography-meta transition-colors flex items-center gap-2',
|
||||
isHighlighted ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
{contextTokens && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0">
|
||||
{contextTokens}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectionCount > 0 && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0">
|
||||
×{selectionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
{/* Add model button (dropdown trigger) */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={CHIP_HEIGHT_CLASS}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5 mr-1" />
|
||||
{addButtonLabel}
|
||||
</Button>
|
||||
|
||||
{isOpen && (() => {
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: Record<string, unknown>; 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 (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 border border-border/30 rounded-xl overflow-hidden bg-background shadow-lg w-[min(380px,calc(100vw-2rem))] flex flex-col">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 pl-8 typography-meta"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<ScrollableOverlay outerClassName="max-h-[400px] flex-1">
|
||||
<div className="p-1">
|
||||
{!hasResults && (
|
||||
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
|
||||
No models found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Section */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
<>
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
</div>
|
||||
{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 && <div className="h-px bg-border/40 my-1" />}
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
</div>
|
||||
{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 && (
|
||||
<div className="h-px bg-border/40 my-1" />
|
||||
)}
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
{index > 0 && <div className="h-px bg-border/40 my-1" />}
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
{provider.name}
|
||||
</div>
|
||||
{provider.models.map((model) => {
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
|
||||
{/* Keyboard hints footer */}
|
||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Selected models */}
|
||||
{showChips && selectedModels.map((model, index) => {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
const totalSameModel = modelCounts.get(key) || 1;
|
||||
const instanceIndex = getInstanceIndex(model);
|
||||
return (
|
||||
<ModelChip
|
||||
key={model.instanceId}
|
||||
model={model}
|
||||
instanceIndex={instanceIndex}
|
||||
totalSameModel={totalSameModel}
|
||||
onRemove={() => onRemove(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Validation hint */}
|
||||
{minModels !== undefined && selectedModels.length < minModels && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Select at least {minModels} model{minModels > 1 ? 's' : ''} {maxModels !== undefined ? `and at most ${maxModels} models` : ''}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={cn('flex items-center gap-1.5 px-2 rounded-md bg-accent/50 border border-border/30', CHIP_HEIGHT_CLASS)}>
|
||||
<ProviderLogo providerId={model.providerID} className="h-3.5 w-3.5" />
|
||||
<span className="typography-meta font-medium truncate max-w-[140px]">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-foreground ml-0.5"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<HTMLInputElement>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Count occurrences of each model for display purposes
|
||||
const modelCounts = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>,
|
||||
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 (
|
||||
<button
|
||||
key={`${keyPrefix}-${key}`}
|
||||
ref={(el) => { itemRefs.current[flatIndex] = el; }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd({
|
||||
providerID,
|
||||
modelID,
|
||||
displayName: (model.name as string) || modelID,
|
||||
instanceId: generateInstanceId(),
|
||||
});
|
||||
// Don't close dropdown - allow selecting multiple
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded-md typography-meta transition-colors flex items-center gap-2',
|
||||
isHighlighted ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
{contextTokens && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0">
|
||||
{contextTokens}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectionCount > 0 && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0">
|
||||
×{selectionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
{/* Add model button (dropdown trigger) */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={CHIP_HEIGHT_CLASS}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5 mr-1" />
|
||||
Add model
|
||||
</Button>
|
||||
|
||||
{isOpen && (() => {
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: Record<string, unknown>; 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 (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 border border-border/30 rounded-xl overflow-hidden bg-background shadow-lg w-[min(380px,calc(100vw-2rem))] flex flex-col">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 pl-8 typography-meta"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<ScrollableOverlay outerClassName="max-h-[400px] flex-1">
|
||||
<div className="p-1">
|
||||
{!hasResults && (
|
||||
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
|
||||
No models found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Section */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
<>
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
</div>
|
||||
{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 && <div className="h-px bg-border/40 my-1" />}
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
</div>
|
||||
{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 && (
|
||||
<div className="h-px bg-border/40 my-1" />
|
||||
)}
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
{index > 0 && <div className="h-px bg-border/40 my-1" />}
|
||||
<div className="typography-ui-header font-semibold text-foreground flex items-center gap-2 px-2 py-1.5">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
{provider.name}
|
||||
</div>
|
||||
{provider.models.map((model) => {
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id, model.id as string, 'provider', idx, selectedIndex === idx);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
|
||||
{/* Keyboard hints footer */}
|
||||
<div className="px-3 pt-1 pb-1.5 border-t border-border/40 typography-micro text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Selected models */}
|
||||
{selectedModels.map((model, index) => {
|
||||
const key = `${model.providerID}:${model.modelID}`;
|
||||
const totalSameModel = modelCounts.get(key) || 1;
|
||||
const instanceIndex = getInstanceIndex(model);
|
||||
return (
|
||||
<ModelChip
|
||||
key={model.instanceId}
|
||||
model={model}
|
||||
instanceIndex={instanceIndex}
|
||||
totalSameModel={totalSameModel}
|
||||
onRemove={() => onRemove(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<MultiRunLauncherProps> = ({
|
||||
return 'pl-3';
|
||||
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
|
||||
|
||||
// Use the BranchSelector hook for branch state management
|
||||
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||
const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState<WorktreeBaseOption[]>([
|
||||
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
|
||||
]);
|
||||
const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(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<MultiRunLauncherProps> = ({
|
||||
}
|
||||
}, [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<MultiRunLauncherProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
@@ -802,72 +290,16 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
>
|
||||
Base branch
|
||||
</label>
|
||||
<Select
|
||||
<BranchSelector
|
||||
directory={currentDirectory}
|
||||
value={worktreeBaseBranch}
|
||||
onValueChange={setWorktreeBaseBranch}
|
||||
disabled={!isGitRepository || isLoadingWorktreeBaseBranches}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="multirun-worktree-base-branch"
|
||||
size="lg"
|
||||
className="max-w-full typography-meta text-foreground"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{availableWorktreeBaseBranches
|
||||
.filter((option) => option.group === 'special')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
{availableWorktreeBaseBranches.some((option) => option.group === 'local') ? (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{availableWorktreeBaseBranches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{availableWorktreeBaseBranches.some((option) => option.group === 'remote') ? (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{availableWorktreeBaseBranches
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="w-auto whitespace-nowrap">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={setWorktreeBaseBranch}
|
||||
id="multirun-worktree-base-branch"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Creates new branches from{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeBaseBranch || 'HEAD'}</code>.
|
||||
</p>
|
||||
{isGitRepository === false ? (
|
||||
<p className="typography-micro text-muted-foreground/70">Not in a git repository.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -948,12 +380,13 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Models <span className="text-destructive">*</span>
|
||||
<span className="ml-1 font-normal text-muted-foreground">(select at least 2, maximum 5)</span>
|
||||
</label>
|
||||
<ModelMultiSelect
|
||||
selectedModels={selectedModels}
|
||||
onAdd={handleAddModel}
|
||||
onRemove={handleRemoveModel}
|
||||
minModels={2}
|
||||
maxModels={MAX_MODELS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AgentGroupDetailProps> = ({
|
||||
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 (
|
||||
<div className={cn('flex h-full flex-col bg-background', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-border/30 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="typography-heading-lg text-foreground truncate">{group.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
|
||||
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3.5 w-3.5" />
|
||||
{selectedSession?.worktreeMetadata?.label || selectedSession?.branch || 'No branch'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selector Dropdown */}
|
||||
{group.sessions.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between h-10 px-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedSession && (
|
||||
<>
|
||||
<ProviderLogo
|
||||
providerId={selectedSession.providerId}
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate typography-body">
|
||||
{selectedSession.modelId}
|
||||
</span>
|
||||
{selectedSession.instanceNumber > 1 && (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
#{selectedSession.instanceNumber}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)]">
|
||||
{group.sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => handleSessionSelect(session)}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<ProviderLogo
|
||||
providerId={session.providerId}
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate typography-body">
|
||||
{session.modelId}
|
||||
</span>
|
||||
{session.instanceNumber > 1 && (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
#{session.instanceNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{session.branch && (
|
||||
<div className="flex items-center gap-1 typography-micro text-muted-foreground/60">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
<span className="truncate">{session.worktreeMetadata?.label || session.branch}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSession?.id === session.id && (
|
||||
<RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{selectedSession ? (
|
||||
isSessionSynced ? (
|
||||
<ChatErrorBoundary sessionId={selectedSession.id}>
|
||||
<ChatContainer />
|
||||
</ChatErrorBoundary>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Info banner about the worktree */}
|
||||
<div className="px-4 py-2 bg-muted/30 border-b border-border/30">
|
||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||
<ProviderLogo providerId={selectedSession.providerId} className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedSession.displayLabel}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono text-xs truncate">
|
||||
{selectedSession.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading or no session state */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<p className="typography-body text-muted-foreground mb-2">
|
||||
Loading session for <span className="font-medium text-foreground">{selectedSession.displayLabel}</span>
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground/60">
|
||||
Session ID: {selectedSession.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="typography-body text-muted-foreground">
|
||||
No sessions in this group
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<AgentManagerEmptyStateProps> = ({
|
||||
className,
|
||||
onCreateGroup,
|
||||
isCreating = false,
|
||||
}) => {
|
||||
const [groupName, setGroupName] = React.useState('');
|
||||
const [prompt, setPrompt] = React.useState('');
|
||||
const [selectedModels, setSelectedModels] = React.useState<ModelSelectionWithId[]>([]);
|
||||
const [baseBranch, setBaseBranch] = React.useState('HEAD');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<AttachedFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(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<HTMLInputElement>) => {
|
||||
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<string>((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 (
|
||||
<div className={cn('flex flex-col items-center justify-center h-full w-full p-4', className)}>
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-4">
|
||||
{/* Group Name Input */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="group-name" className="typography-ui-label font-medium text-foreground">
|
||||
Group Name
|
||||
</label>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="e.g. feature-auth, bugfix-login"
|
||||
className="typography-body"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Used for worktree directory and branch naming
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Branch Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-1.5">
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
||||
Base Branch
|
||||
</label>
|
||||
<BranchSelector
|
||||
directory={currentDirectory}
|
||||
value={baseBranch}
|
||||
onChange={setBaseBranch}
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Creates new branches from <code className="font-mono text-xs">{baseBranch}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Models
|
||||
</label>
|
||||
<ModelMultiSelect
|
||||
selectedModels={selectedModels}
|
||||
onAdd={handleAddModel}
|
||||
onRemove={handleRemoveModel}
|
||||
minModels={1}
|
||||
addButtonLabel="Add model"
|
||||
maxModels={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Input Style Prompt */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="prompt" className="typography-ui-label font-medium text-foreground">
|
||||
Prompt
|
||||
</label>
|
||||
<div className="rounded-xl border border-border/60 bg-input/10 dark:bg-input/30 overflow-hidden">
|
||||
{/* Text Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Ask anything..."
|
||||
className="min-h-[100px] max-h-[300px] resize-none border-0 bg-transparent px-4 py-3 typography-markdown focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
|
||||
{/* Attached Files Display */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-2">
|
||||
{attachedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/30 border border-border/30 rounded-md typography-meta"
|
||||
>
|
||||
{file.mimeType.startsWith('image/') ? (
|
||||
<RiFileImageLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<RiFileLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]" title={file.filename}>
|
||||
{file.filename}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Controls */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/40">
|
||||
{/* Left Controls - Attachments */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept="*/*"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Add attachment"
|
||||
>
|
||||
<RiAddCircleLine className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Controls - Model Count */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{selectedModels.length} model{selectedModels.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmittingOrCreating}
|
||||
className={cn(
|
||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0',
|
||||
isValid
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'opacity-30'
|
||||
)}
|
||||
aria-label="Start Agent Group"
|
||||
>
|
||||
{isSubmittingOrCreating ? (
|
||||
<RiHourglassFill className="h-[18px] w-[18px] animate-spin" />
|
||||
) : (
|
||||
<RiSendPlane2Line className="h-[18px] w-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiMore2Line,
|
||||
RiSearchLine,
|
||||
RiGitBranchLine,
|
||||
} from '@remixicon/react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
|
||||
const minutes = Math.floor(diff / (60 * 1000));
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (minutes < 1) return 'now';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${days}d`;
|
||||
};
|
||||
|
||||
interface AgentGroupItemProps {
|
||||
group: AgentGroup;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1.5 cursor-pointer',
|
||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/60">
|
||||
{formatRelativeTime(group.lastActive)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 self-stretch">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-3.5 w-[18px] items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
menuOpen && 'opacity-100',
|
||||
)}
|
||||
aria-label="Group menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentManagerSidebarProps {
|
||||
className?: string;
|
||||
selectedGroupName?: string | null;
|
||||
onGroupSelect?: (groupName: string) => void;
|
||||
onNewAgent?: () => void;
|
||||
}
|
||||
|
||||
export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
||||
className,
|
||||
selectedGroupName,
|
||||
onGroupSelect,
|
||||
onNewAgent,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
|
||||
const { groups, isLoading, loadGroups } = useAgentGroupsStore();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
// Load groups when directory changes
|
||||
React.useEffect(() => {
|
||||
if (currentDirectory) {
|
||||
loadGroups();
|
||||
}
|
||||
}, [currentDirectory, loadGroups]);
|
||||
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) return groups;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return groups.filter(group =>
|
||||
group.name.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery, groups]);
|
||||
|
||||
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
||||
const remainingCount = filteredGroups.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col bg-background/50 dark:bg-neutral-900/80 text-foreground border-r border-border/30', className)}>
|
||||
{/* Search Input */}
|
||||
<div className="px-2.5 pt-3 pb-2">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search Agent Groups..."
|
||||
className="pl-8 h-8 rounded-lg border-border/40 bg-background/50 typography-meta"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Agent Button */}
|
||||
<div className="px-2.5 pb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 h-8"
|
||||
onClick={onNewAgent}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label">New Agent Group</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Agent Groups Section Header */}
|
||||
<div className="px-2.5 py-1.5 flex items-center gap-1">
|
||||
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="typography-micro font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agent Groups
|
||||
</span>
|
||||
{isLoading && (
|
||||
<span className="typography-micro text-muted-foreground/50 ml-auto">
|
||||
Loading...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group List */}
|
||||
<ScrollableOverlay
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className="space-y-0.5 px-2.5 pb-2"
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<AgentGroupItem
|
||||
key={group.name}
|
||||
group={group}
|
||||
isSelected={selectedGroupName === group.name}
|
||||
onSelect={() => onGroupSelect?.(group.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show More Link */}
|
||||
{!showAll && remainingCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
|
||||
>
|
||||
... More ({remainingCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Less Link */}
|
||||
{showAll && filteredGroups.length > MAX_VISIBLE && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(false)}
|
||||
className="mt-1 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left typography-micro text-muted-foreground/70 hover:text-foreground hover:underline"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredGroups.length === 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{searchQuery.trim() ? 'No groups found' : 'No agent groups yet'}
|
||||
</p>
|
||||
{!searchQuery.trim() && (
|
||||
<p className="typography-micro text-muted-foreground/60 mt-1">
|
||||
Create a new agent group to get started
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { AgentManagerSidebar } from './AgentManagerSidebar';
|
||||
import { AgentManagerEmptyState } from './AgentManagerEmptyState';
|
||||
import { AgentGroupDetail } from './AgentGroupDetail';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
|
||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||
import type { CreateMultiRunParams } from '@/types/multirun';
|
||||
|
||||
interface AgentManagerViewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className }) => {
|
||||
const {
|
||||
selectedGroupName,
|
||||
selectGroup,
|
||||
getSelectedGroup,
|
||||
loadGroups,
|
||||
} = useAgentGroupsStore();
|
||||
|
||||
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
|
||||
|
||||
const handleGroupSelect = React.useCallback((groupName: string) => {
|
||||
selectGroup(groupName);
|
||||
}, [selectGroup]);
|
||||
|
||||
const handleNewAgent = React.useCallback(() => {
|
||||
// Clear selection to show the empty state / new agent form
|
||||
selectGroup(null);
|
||||
}, [selectGroup]);
|
||||
|
||||
const handleCreateGroup = React.useCallback(async (params: CreateMultiRunParams) => {
|
||||
toast.info(`Creating agent group "${params.name}" with ${params.models.length} model(s)...`);
|
||||
|
||||
const result = await createMultiRun(params);
|
||||
|
||||
if (result) {
|
||||
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
|
||||
// Reload groups to pick up the new worktrees and sessions
|
||||
await loadGroups();
|
||||
// Select the newly created group
|
||||
selectGroup(params.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 50));
|
||||
} else {
|
||||
const error = useMultiRunStore.getState().error;
|
||||
toast.error(error || 'Failed to create agent group');
|
||||
}
|
||||
}, [createMultiRun, loadGroups, selectGroup]);
|
||||
|
||||
const selectedGroup = getSelectedGroup();
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full w-full bg-background', className)}>
|
||||
{/* Left Sidebar - Agent Groups List */}
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<AgentManagerSidebar
|
||||
selectedGroupName={selectedGroupName}
|
||||
onGroupSelect={handleGroupSelect}
|
||||
onNewAgent={handleNewAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedGroup ? (
|
||||
<AgentGroupDetail group={selectedGroup} />
|
||||
) : (
|
||||
<AgentManagerEmptyState
|
||||
onCreateGroup={handleCreateGroup}
|
||||
isCreating={isCreatingMultiRun}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AgentManagerView } from './AgentManagerView';
|
||||
export { AgentManagerSidebar } from './AgentManagerSidebar';
|
||||
export { AgentManagerEmptyState } from './AgentManagerEmptyState';
|
||||
Reference in New Issue
Block a user