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:
wienans
2026-01-03 23:11:16 +02:00
committed by GitHub
parent 9887b25864
commit 0bccf71996
25 changed files with 2417 additions and 730 deletions
+19
View File
@@ -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 (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<div className="h-full text-foreground bg-background">
<AgentManagerView />
<Toaster />
</div>
</RuntimeAPIProvider>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
@@ -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';
+6
View File
@@ -400,6 +400,11 @@ export interface EditorAPI {
openDiff(original: string, modified: string, label?: string): Promise<void>;
}
export interface VSCodeAPI {
executeCommand(command: string, ...args: unknown[]): Promise<unknown>;
openAgentManager(): Promise<void>;
}
export interface RuntimeAPIs {
runtime: RuntimeDescriptor;
terminal: TerminalAPI;
@@ -411,6 +416,7 @@ export interface RuntimeAPIs {
diagnostics?: DiagnosticsAPI;
tools: ToolsAPI;
editor?: EditorAPI;
vscode?: VSCodeAPI;
worktrees?: WorktreeMetadata[];
}
@@ -0,0 +1,303 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryStore } from './useDirectoryStore';
import type { WorktreeMetadata } from '@/types/worktree';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
import type { Session } from '@opencode-ai/sdk';
const OPENCHAMBER_DIR = '.openchamber';
/**
* Agent group session parsed from OpenCode session titles.
* Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index`
* Model can contain `/` for creator/model format (e.g., `anthropic/claude-opus-4-5`)
*
* Examples:
* - `feature/opencode/claude-sonnet-4-5` → group="feature", provider="opencode", model="claude-sonnet-4-5"
* - `feature/opencode/claude-sonnet-4-1/2` → group="feature", provider="opencode", model="claude-sonnet-4-1", index=2
* - `feature/openrouter/anthropic/claude-opus-4-5` → group="feature", provider="openrouter", model="anthropic/claude-opus-4-5"
*/
export interface AgentGroupSession {
/** OpenCode session ID */
id: string;
/** Full worktree path (from session.directory) */
path: string;
/** Provider ID extracted from title */
providerId: string;
/** Model ID extracted from title (may contain / for creator/model format) */
modelId: string;
/** Instance number for duplicate model selections (default: 1) */
instanceNumber: number;
/** Branch name associated with this worktree */
branch: string;
/** Display label for the model */
displayLabel: string;
/** Full worktree metadata */
worktreeMetadata?: WorktreeMetadata;
}
export interface AgentGroup {
/** Group name (e.g., "agent-manager-2", "contributing") */
name: string;
/** Sessions within this group (one per model instance) */
sessions: AgentGroupSession[];
/** Timestamp of last activity (most recent session update) */
lastActive: number;
/** Total session count */
sessionCount: number;
}
interface AgentGroupsState {
/** All discovered agent groups from session titles */
groups: AgentGroup[];
/** Currently selected group name */
selectedGroupName: string | null;
/** Currently selected session ID within the group */
selectedSessionId: string | null;
/** Loading state */
isLoading: boolean;
/** Error message */
error: string | null;
}
interface AgentGroupsActions {
/** Load/refresh agent groups from OpenCode sessions */
loadGroups: () => Promise<void>;
/** Select a group */
selectGroup: (groupName: string | null) => void;
/** Select a session within the current group */
selectSession: (sessionId: string | null) => void;
/** Get the currently selected group */
getSelectedGroup: () => AgentGroup | null;
/** Get the currently selected session */
getSelectedSession: () => AgentGroupSession | null;
/** Clear error */
clearError: () => void;
}
type AgentGroupsStore = AgentGroupsState & AgentGroupsActions;
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.replace(/\/+$/, '');
};
/**
* Parse a session title to extract group, provider, model, and index.
* Title format: groupSlug/provider/model[/index]
*
* The groupSlug is always the first segment (cannot contain `/` as it's sanitized).
* The provider is always the second segment.
* Everything after the provider (excluding numeric index) is the model.
* Model can contain `/` for creator/model format.
*
* Examples:
* - "feature/opencode/claude-sonnet-4-5" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-5", index: 1 }
* - "feature/opencode/claude-sonnet-4-1/2" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-1", index: 2 }
* - "feature/openrouter/anthropic/claude-opus-4-5" → { groupSlug: "feature", provider: "openrouter", model: "anthropic/claude-opus-4-5", index: 1 }
* - "my-task/anthropic/claude-sonnet-4/1" → { groupSlug: "my-task", provider: "anthropic", model: "claude-sonnet-4", index: 1 }
*/
function parseSessionTitle(title: string | undefined): {
groupSlug: string;
provider: string;
model: string;
index: number;
} | null {
if (!title) return null;
const parts = title.split('/');
if (parts.length < 3) return null;
// First part is always groupSlug (cannot contain / or spaces as it's sanitized by toGitSafeSlug)
const groupSlug = parts[0];
if (!groupSlug || groupSlug.includes(' ')) return null;
// Second part is always provider
const provider = parts[1];
if (!provider) return null;
// Check if last part is a numeric index
const lastPart = parts[parts.length - 1];
const lastPartNum = parseInt(lastPart, 10);
const hasIndex = parts.length >= 4 && !isNaN(lastPartNum) && String(lastPartNum) === lastPart;
// Model is everything from parts[2] to end (excluding index if present)
const modelParts = hasIndex
? parts.slice(2, -1)
: parts.slice(2);
// Must have at least one model part
if (modelParts.length === 0) {
return null;
}
const model = modelParts.join('/');
return {
groupSlug,
provider,
model,
index: hasIndex ? lastPartNum : 1,
};
}
export const useAgentGroupsStore = create<AgentGroupsStore>()(
devtools(
(set, get) => ({
groups: [],
selectedGroupName: null,
selectedSessionId: null,
isLoading: false,
error: null,
loadGroups: async () => {
const currentDirectory = useDirectoryStore.getState().currentDirectory;
if (!currentDirectory) {
set({ groups: [], isLoading: false, error: 'No project directory selected' });
return;
}
// Check if we're inside a .openchamber worktree - if so, don't reload
// This prevents groups from disappearing when switching to a worktree session
const normalizedCurrent = normalize(currentDirectory);
if (normalizedCurrent.includes(`/${OPENCHAMBER_DIR}/`)) {
// We're inside a worktree, don't reload groups
set({ isLoading: false });
return;
}
const previousGroups = get().groups;
set({ isLoading: true, error: null });
try {
const apiClient = opencodeClient.getApiClient();
// Fetch all sessions from the main project directory
// All worktree sessions are visible from here
const response = await apiClient.session.list({
query: { directory: normalizedCurrent },
});
const allSessions: Session[] = Array.isArray(response.data) ? response.data : [];
// Get git worktree info for metadata
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
try {
const worktreeInfoList = await listWorktrees(normalizedCurrent);
worktreeInfoMap = new Map(
worktreeInfoList.map((info) => [normalize(info.worktree), info])
);
} catch {
console.debug('Failed to list git worktrees');
}
// Parse sessions and group by groupSlug
const groupsMap = new Map<string, AgentGroupSession[]>();
for (const session of allSessions) {
const parsed = parseSessionTitle(session.title);
if (!parsed) continue; // Skip sessions without valid agent group title
const sessionPath = normalize(session.directory);
const worktreeInfo = worktreeInfoMap.get(sessionPath);
const agentSession: AgentGroupSession = {
id: session.id,
path: sessionPath,
providerId: parsed.provider,
modelId: parsed.model,
instanceNumber: parsed.index,
branch: worktreeInfo?.branch ?? '',
displayLabel: `${parsed.provider}/${parsed.model}`,
worktreeMetadata: worktreeInfo
? mapWorktreeToMetadata(normalizedCurrent, worktreeInfo)
: undefined,
};
const existing = groupsMap.get(parsed.groupSlug);
if (existing) {
existing.push(agentSession);
} else {
groupsMap.set(parsed.groupSlug, [agentSession]);
}
}
// Convert map to array and sort
const groups: AgentGroup[] = Array.from(groupsMap.entries()).map(
([name, sessions]) => {
// Find the most recent session update time for lastActive
const lastActive = sessions.reduce((max, s) => {
// Find the original session to get the time
const originalSession = allSessions.find((os) => os.id === s.id);
const updatedTime = originalSession?.time?.updated ?? 0;
return Math.max(max, updatedTime);
}, 0);
return {
name,
sessions: sessions.sort((a, b) => {
// Sort by provider, then model, then instance
const providerCmp = a.providerId.localeCompare(b.providerId);
if (providerCmp !== 0) return providerCmp;
const modelCmp = a.modelId.localeCompare(b.modelId);
if (modelCmp !== 0) return modelCmp;
return a.instanceNumber - b.instanceNumber;
}),
lastActive: lastActive || Date.now(),
sessionCount: sessions.length,
};
}
);
// Sort groups by name
groups.sort((a, b) => a.name.localeCompare(b.name));
set({ groups, isLoading: false, error: null });
} catch (err) {
console.error('Failed to load agent groups:', err);
// Preserve existing groups on error to avoid UI flickering
set({
groups: previousGroups.length > 0 ? previousGroups : [],
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to load agent groups',
});
}
},
selectGroup: (groupName) => {
const { groups } = get();
const group = groups.find((g) => g.name === groupName);
set({
selectedGroupName: groupName,
// Auto-select first session when selecting a group
selectedSessionId: group?.sessions[0]?.id ?? null,
});
},
selectSession: (sessionId) => {
set({ selectedSessionId: sessionId });
},
getSelectedGroup: () => {
const { groups, selectedGroupName } = get();
if (!selectedGroupName) return null;
return groups.find((g) => g.name === selectedGroupName) ?? null;
},
getSelectedSession: () => {
const { selectedSessionId } = get();
const group = get().getSelectedGroup();
if (!group || !selectedSessionId) return null;
return group.sessions.find((s) => s.id === selectedSessionId) ?? null;
},
clearError: () => {
set({ error: null });
},
}),
{ name: 'agent-groups-store' }
)
);
+7 -2
View File
@@ -145,7 +145,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
const modelSlug = toModelSlug(model.providerID, model.modelID);
// Append index only when same model is selected multiple times
const branch = count > 1
? generateBranchName(groupSlug, `${modelSlug}-${index}`)
? generateBranchName(groupSlug, `${modelSlug}/${index}`)
: generateBranchName(groupSlug, modelSlug);
if (!branch) {
@@ -168,9 +168,14 @@ export const useMultiRunStore = create<MultiRunStore>()(
startPoint,
});
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
const sessionTitle = count > 1
? `${groupSlug}/${model.providerID}/${model.modelID}/${index}`
: `${groupSlug}/${model.providerID}/${model.modelID}`;
const session = await opencodeClient.withDirectory(
worktreeMetadata.path,
() => opencodeClient.createSession({ title: `${model.providerID}/${model.modelID}` })
() => opencodeClient.createSession({ title: sessionTitle })
);
useSessionStore.getState().setWorktreeMetadata(session.id, worktreeMetadata);