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:
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- VS Code added Agent Manager, run the same promt in parallel with up to 5 models.
|
||||||
|
- Change in Branch and Session Naming for Multi Run, <groupSlug>/<provider>/<modelSlug>/<index>
|
||||||
|
|
||||||
## [1.4.2] - 2026-01-02
|
## [1.4.2] - 2026-01-02
|
||||||
|
|
||||||
- Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt).
|
- Added timeline dialog (`/timeline` command or Cmd/Ctrl+T) for navigating, reverting, and forking from any point in the conversation (thanks to @aptdnfapt).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||||
|
import { AgentManagerView } from '@/components/views/agent-manager';
|
||||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||||
@@ -191,6 +192,24 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
// VS Code runtime - simplified layout without git/terminal views
|
// VS Code runtime - simplified layout without git/terminal views
|
||||||
if (isVSCodeRuntime) {
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RuntimeAPIProvider apis={apis}>
|
<RuntimeAPIProvider apis={apis}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChatView, SettingsView } from '@/components/views';
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
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
|
// Width threshold for mobile vs desktop layout in settings
|
||||||
const MOBILE_WIDTH_THRESHOLD = 550;
|
const MOBILE_WIDTH_THRESHOLD = 550;
|
||||||
@@ -195,10 +195,11 @@ interface VSCodeHeaderProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onNewSession?: () => void;
|
onNewSession?: () => void;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
|
onAgentManager?: () => void;
|
||||||
showContextUsage?: boolean;
|
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 { getCurrentModel } = useConfigStore();
|
||||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
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" />
|
<RiAddLine className="h-5 w-5" />
|
||||||
</button>
|
</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 && (
|
{onSettings && (
|
||||||
<button
|
<button
|
||||||
onClick={onSettings}
|
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 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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { cn } from '@/lib/utils';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useModelLists } from '@/hooks/useModelLists';
|
|
||||||
import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun';
|
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) */
|
/** Max file size in bytes (10MB) */
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
@@ -43,13 +29,6 @@ interface MultiRunAttachedFile {
|
|||||||
dataUrl: string;
|
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 {
|
interface MultiRunLauncherProps {
|
||||||
/** Prefill prompt textarea (optional) */
|
/** Prefill prompt textarea (optional) */
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
@@ -59,425 +38,6 @@ interface MultiRunLauncherProps {
|
|||||||
onCancel?: () => void;
|
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.
|
* Launcher form for creating a new Multi-Run group.
|
||||||
* Replaces the main content area (tabs) with a form.
|
* Replaces the main content area (tabs) with a form.
|
||||||
@@ -526,12 +86,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
return 'pl-3';
|
return 'pl-3';
|
||||||
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
|
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
|
||||||
|
|
||||||
|
// Use the BranchSelector hook for branch state management
|
||||||
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||||
const [availableWorktreeBaseBranches, setAvailableWorktreeBaseBranches] = React.useState<WorktreeBaseOption[]>([
|
const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory);
|
||||||
{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' },
|
|
||||||
]);
|
|
||||||
const [isLoadingWorktreeBaseBranches, setIsLoadingWorktreeBaseBranches] = React.useState(false);
|
|
||||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
|
||||||
|
|
||||||
const createMultiRun = useMultiRunStore((state) => state.createMultiRun);
|
const createMultiRun = useMultiRunStore((state) => state.createMultiRun);
|
||||||
const error = useMultiRunStore((state) => state.error);
|
const error = useMultiRunStore((state) => state.error);
|
||||||
@@ -543,74 +100,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
}
|
}
|
||||||
}, [initialPrompt]);
|
}, [initialPrompt]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
if (!currentDirectory) {
|
|
||||||
setIsGitRepository(null);
|
|
||||||
setIsLoadingWorktreeBaseBranches(false);
|
|
||||||
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
|
||||||
setWorktreeBaseBranch('HEAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingWorktreeBaseBranches(true);
|
|
||||||
setIsGitRepository(null);
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const isGit = await checkIsGitRepository(currentDirectory);
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
setIsGitRepository(isGit);
|
|
||||||
|
|
||||||
if (!isGit) {
|
|
||||||
setAvailableWorktreeBaseBranches([{ value: 'HEAD', label: 'Current (HEAD)', group: 'special' }]);
|
|
||||||
setWorktreeBaseBranch('HEAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branches = await getGitBranches(currentDirectory).catch(() => null);
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const worktreeBaseOptions: WorktreeBaseOption[] = [];
|
|
||||||
const headLabel = branches?.current ? `Current (HEAD: ${branches.current})` : 'Current (HEAD)';
|
|
||||||
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
|
||||||
|
|
||||||
if (branches) {
|
|
||||||
const localBranches = branches.all
|
|
||||||
.filter((branchName) => !branchName.startsWith('remotes/'))
|
|
||||||
.sort((a, b) => a.localeCompare(b));
|
|
||||||
localBranches.forEach((branchName) => {
|
|
||||||
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'local' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const remoteBranches = branches.all
|
|
||||||
.filter((branchName) => branchName.startsWith('remotes/'))
|
|
||||||
.map((branchName) => branchName.replace(/^remotes\//, ''))
|
|
||||||
.sort((a, b) => a.localeCompare(b));
|
|
||||||
remoteBranches.forEach((branchName) => {
|
|
||||||
worktreeBaseOptions.push({ value: branchName, label: branchName, group: 'remote' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableWorktreeBaseBranches(worktreeBaseOptions);
|
|
||||||
setWorktreeBaseBranch((previous) =>
|
|
||||||
worktreeBaseOptions.some((option) => option.value === previous) ? previous : 'HEAD'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoadingWorktreeBaseBranches(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [currentDirectory]);
|
|
||||||
|
|
||||||
|
|
||||||
const handleAddModel = (model: ModelSelectionWithId) => {
|
const handleAddModel = (model: ModelSelectionWithId) => {
|
||||||
if (selectedModels.length >= MAX_MODELS) {
|
if (selectedModels.length >= MAX_MODELS) {
|
||||||
return;
|
return;
|
||||||
@@ -683,7 +172,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
@@ -802,72 +290,16 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
>
|
>
|
||||||
Base branch
|
Base branch
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<BranchSelector
|
||||||
|
directory={currentDirectory}
|
||||||
value={worktreeBaseBranch}
|
value={worktreeBaseBranch}
|
||||||
onValueChange={setWorktreeBaseBranch}
|
onChange={setWorktreeBaseBranch}
|
||||||
disabled={!isGitRepository || isLoadingWorktreeBaseBranches}
|
id="multirun-worktree-base-branch"
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Creates new branches from{' '}
|
Creates new branches from{' '}
|
||||||
<code className="font-mono text-xs text-muted-foreground">{worktreeBaseBranch || 'HEAD'}</code>.
|
<code className="font-mono text-xs text-muted-foreground">{worktreeBaseBranch || 'HEAD'}</code>.
|
||||||
</p>
|
</p>
|
||||||
{isGitRepository === false ? (
|
|
||||||
<p className="typography-micro text-muted-foreground/70">Not in a git repository.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -948,12 +380,13 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="typography-ui-label font-medium text-foreground">
|
<label className="typography-ui-label font-medium text-foreground">
|
||||||
Models <span className="text-destructive">*</span>
|
Models <span className="text-destructive">*</span>
|
||||||
<span className="ml-1 font-normal text-muted-foreground">(select at least 2, maximum 5)</span>
|
|
||||||
</label>
|
</label>
|
||||||
<ModelMultiSelect
|
<ModelMultiSelect
|
||||||
selectedModels={selectedModels}
|
selectedModels={selectedModels}
|
||||||
onAdd={handleAddModel}
|
onAdd={handleAddModel}
|
||||||
onRemove={handleRemoveModel}
|
onRemove={handleRemoveModel}
|
||||||
|
minModels={2}
|
||||||
|
maxModels={MAX_MODELS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export { MultiRunLauncher } from './MultiRunLauncher';
|
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';
|
||||||
@@ -400,6 +400,11 @@ export interface EditorAPI {
|
|||||||
openDiff(original: string, modified: string, label?: string): Promise<void>;
|
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 {
|
export interface RuntimeAPIs {
|
||||||
runtime: RuntimeDescriptor;
|
runtime: RuntimeDescriptor;
|
||||||
terminal: TerminalAPI;
|
terminal: TerminalAPI;
|
||||||
@@ -411,6 +416,7 @@ export interface RuntimeAPIs {
|
|||||||
diagnostics?: DiagnosticsAPI;
|
diagnostics?: DiagnosticsAPI;
|
||||||
tools: ToolsAPI;
|
tools: ToolsAPI;
|
||||||
editor?: EditorAPI;
|
editor?: EditorAPI;
|
||||||
|
vscode?: VSCodeAPI;
|
||||||
worktrees?: WorktreeMetadata[];
|
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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -145,7 +145,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
||||||
// Append index only when same model is selected multiple times
|
// Append index only when same model is selected multiple times
|
||||||
const branch = count > 1
|
const branch = count > 1
|
||||||
? generateBranchName(groupSlug, `${modelSlug}-${index}`)
|
? generateBranchName(groupSlug, `${modelSlug}/${index}`)
|
||||||
: generateBranchName(groupSlug, modelSlug);
|
: generateBranchName(groupSlug, modelSlug);
|
||||||
|
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
@@ -168,9 +168,14 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
startPoint,
|
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(
|
const session = await opencodeClient.withDirectory(
|
||||||
worktreeMetadata.path,
|
worktreeMetadata.path,
|
||||||
() => opencodeClient.createSession({ title: `${model.providerID}/${model.modelID}` })
|
() => opencodeClient.createSession({ title: sessionTitle })
|
||||||
);
|
);
|
||||||
|
|
||||||
useSessionStore.getState().setWorktreeMetadata(session.id, worktreeMetadata);
|
useSessionStore.getState().setWorktreeMetadata(session.id, worktreeMetadata);
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "openchamber.openSidebar",
|
"command": "openchamber.openSidebar",
|
||||||
"title": "Open OpenChamber Sidebar",
|
"category": "OpenChamber",
|
||||||
|
"title": "Open Sidebar",
|
||||||
"icon": {
|
"icon": {
|
||||||
"light": "assets/icon.svg",
|
"light": "assets/icon.svg",
|
||||||
"dark": "assets/icon-titlebar.svg"
|
"dark": "assets/icon-titlebar.svg"
|
||||||
@@ -64,35 +65,49 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.focusChat",
|
"command": "openchamber.focusChat",
|
||||||
"title": "OpenChamber: Focus Chat"
|
"category": "OpenChamber",
|
||||||
|
"title": "Focus Chat"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.restartApi",
|
"command": "openchamber.restartApi",
|
||||||
"title": "OpenChamber: Restart API Connection"
|
"category": "OpenChamber",
|
||||||
|
"title": "Restart API Connection"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.showOpenCodeStatus",
|
"command": "openchamber.showOpenCodeStatus",
|
||||||
"title": "OpenChamber: Show OpenCode Status"
|
"category": "OpenChamber",
|
||||||
|
"title": "Show OpenCode Status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openchamber.openAgentManager",
|
||||||
|
"category": "OpenChamber",
|
||||||
|
"title": "Open Agent Manager",
|
||||||
|
"icon": "$(circuit-board)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.addToContext",
|
"command": "openchamber.addToContext",
|
||||||
|
"category": "OpenChamber",
|
||||||
"title": "Add to Context"
|
"title": "Add to Context"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.explain",
|
"command": "openchamber.explain",
|
||||||
|
"category": "OpenChamber",
|
||||||
"title": "Explain"
|
"title": "Explain"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.improveCode",
|
"command": "openchamber.improveCode",
|
||||||
|
"category": "OpenChamber",
|
||||||
"title": "Improve Code"
|
"title": "Improve Code"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.newSession",
|
"command": "openchamber.newSession",
|
||||||
|
"category": "OpenChamber",
|
||||||
"title": "New Session",
|
"title": "New Session",
|
||||||
"icon": "$(add)"
|
"icon": "$(add)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.showSettings",
|
"command": "openchamber.showSettings",
|
||||||
|
"category": "OpenChamber",
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"icon": "$(settings-gear)"
|
"icon": "$(settings-gear)"
|
||||||
}
|
}
|
||||||
@@ -123,9 +138,14 @@
|
|||||||
"group": "navigation@1"
|
"group": "navigation@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "openchamber.showSettings",
|
"command": "openchamber.openAgentManager",
|
||||||
"when": "view == openchamber.chatView",
|
"when": "view == openchamber.chatView",
|
||||||
"group": "navigation@2"
|
"group": "navigation@2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openchamber.showSettings",
|
||||||
|
"when": "view == openchamber.chatView",
|
||||||
|
"group": "navigation@3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"openchamber.submenu": [
|
"openchamber.submenu": [
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||||
|
import { getThemeKindName } from './theme';
|
||||||
|
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||||
|
import { getWebviewShikiThemes } from './shikiThemes';
|
||||||
|
import { getWebviewHtml } from './webviewHtml';
|
||||||
|
|
||||||
|
export class AgentManagerPanelProvider {
|
||||||
|
public static readonly viewType = 'openchamber.agentManager';
|
||||||
|
|
||||||
|
private _panel?: vscode.WebviewPanel;
|
||||||
|
|
||||||
|
// Cache latest status/URL for when webview is resolved after connection is ready
|
||||||
|
private _cachedStatus: ConnectionStatus = 'connecting';
|
||||||
|
private _cachedError?: string;
|
||||||
|
private _sseCounter = 0;
|
||||||
|
private _sseStreams = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _context: vscode.ExtensionContext,
|
||||||
|
private readonly _extensionUri: vscode.Uri,
|
||||||
|
private readonly _openCodeManager?: OpenCodeManager
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createOrShow(): void {
|
||||||
|
// If panel exists, reveal it
|
||||||
|
if (this._panel) {
|
||||||
|
this._panel.reveal(vscode.ViewColumn.One);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
|
||||||
|
|
||||||
|
// Create new panel
|
||||||
|
this._panel = vscode.window.createWebviewPanel(
|
||||||
|
AgentManagerPanelProvider.viewType,
|
||||||
|
'Agent Manager',
|
||||||
|
vscode.ViewColumn.One,
|
||||||
|
{
|
||||||
|
enableScripts: true,
|
||||||
|
retainContextWhenHidden: true,
|
||||||
|
localResourceRoots: [this._extensionUri, distUri],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this._panel.webview.html = this._getHtmlForWebview(this._panel.webview);
|
||||||
|
|
||||||
|
// Send theme payload (including optional Shiki theme JSON) after the webview is set up.
|
||||||
|
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
||||||
|
|
||||||
|
// Send cached connection status
|
||||||
|
this._sendCachedState();
|
||||||
|
|
||||||
|
// Handle panel disposal
|
||||||
|
this._panel.onDidDispose(() => {
|
||||||
|
// Clean up SSE streams
|
||||||
|
for (const controller of this._sseStreams.values()) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
this._sseStreams.clear();
|
||||||
|
this._panel = undefined;
|
||||||
|
}, null, this._context.subscriptions);
|
||||||
|
|
||||||
|
// Handle messages
|
||||||
|
this._panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||||
|
if (message.type === 'restartApi') {
|
||||||
|
await this._openCodeManager?.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'api:sse:start') {
|
||||||
|
const response = await this._startSseProxy(message);
|
||||||
|
this._panel?.webview.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'api:sse:stop') {
|
||||||
|
const response = await this._stopSseProxy(message);
|
||||||
|
this._panel?.webview.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await handleBridgeMessage(message, {
|
||||||
|
manager: this._openCodeManager,
|
||||||
|
context: this._context,
|
||||||
|
});
|
||||||
|
this._panel?.webview.postMessage(response);
|
||||||
|
}, null, this._context.subscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateTheme(kind: vscode.ColorThemeKind) {
|
||||||
|
if (this._panel) {
|
||||||
|
const themeKind = getThemeKindName(kind);
|
||||||
|
void getWebviewShikiThemes().then((shikiThemes) => {
|
||||||
|
this._panel?.webview.postMessage({
|
||||||
|
type: 'themeChange',
|
||||||
|
theme: { kind: themeKind, shikiThemes },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
|
||||||
|
// Cache the latest state
|
||||||
|
this._cachedStatus = status;
|
||||||
|
this._cachedError = error;
|
||||||
|
|
||||||
|
// Send to webview if it exists
|
||||||
|
this._sendCachedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendCachedState() {
|
||||||
|
if (!this._panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._panel.webview.postMessage({
|
||||||
|
type: 'connectionStatus',
|
||||||
|
status: this._cachedStatus,
|
||||||
|
error: this._cachedError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
...(extra || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
result[key] = value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||||
|
const { id, type, payload } = message;
|
||||||
|
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||||
|
|
||||||
|
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||||
|
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||||
|
|
||||||
|
if (!apiBaseUrl) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
||||||
|
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this._buildSseHeaders(headers || {}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseHeaders = this._collectHeaders(response.headers);
|
||||||
|
const responseBody = response.body;
|
||||||
|
if (!response.ok || !responseBody) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: response.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
streamId: null,
|
||||||
|
error: `SSE failed: ${response.status}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sseStreams.set(streamId, controller);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const reader = responseBody.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let sseBuffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
if (value && value.length > 0) {
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
if (!chunk) continue;
|
||||||
|
|
||||||
|
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||||
|
sseBuffer += chunk;
|
||||||
|
const blocks = sseBuffer.split('\n\n');
|
||||||
|
sseBuffer = blocks.pop() ?? '';
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
const joined = blocks.map((block) => `${block}\n\n`).join('');
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = decoder.decode();
|
||||||
|
if (tail) {
|
||||||
|
sseBuffer += tail;
|
||||||
|
}
|
||||||
|
if (sseBuffer) {
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this._panel?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._sseStreams.delete(streamId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: response.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
streamId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||||
|
const { id, type, payload } = message;
|
||||||
|
const { streamId } = (payload || {}) as { streamId?: string };
|
||||||
|
if (typeof streamId === 'string' && streamId.length > 0) {
|
||||||
|
const controller = this._sseStreams.get(streamId);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
this._sseStreams.delete(streamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id, type, success: true, data: { stopped: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getHtmlForWebview(webview: vscode.Webview): string {
|
||||||
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
|
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||||
|
|
||||||
|
return getWebviewHtml({
|
||||||
|
webview,
|
||||||
|
extensionUri: this._extensionUri,
|
||||||
|
workspaceFolder,
|
||||||
|
initialStatus: this._cachedStatus,
|
||||||
|
cliAvailable,
|
||||||
|
panelType: 'agentManager',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './
|
|||||||
import { getThemeKindName } from './theme';
|
import { getThemeKindName } from './theme';
|
||||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||||
import { getWebviewShikiThemes } from './shikiThemes';
|
import { getWebviewShikiThemes } from './shikiThemes';
|
||||||
|
import { getWebviewHtml } from './webviewHtml';
|
||||||
|
|
||||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = 'openchamber.chatView';
|
public static readonly viewType = 'openchamber.chatView';
|
||||||
@@ -307,153 +308,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getHtmlForWebview(webview: vscode.Webview) {
|
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||||
const scriptPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
|
||||||
const scriptUri = webview.asWebviewUri(scriptPath);
|
|
||||||
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
|
||||||
// Use cached values which are updated by onStatusChange callback
|
// Use cached values which are updated by onStatusChange callback
|
||||||
const initialStatus = this._cachedStatus;
|
const initialStatus = this._cachedStatus;
|
||||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||||
|
|
||||||
// Use VS Code CSS variables for proper theme integration
|
return getWebviewHtml({
|
||||||
// These variables are automatically provided by VS Code to webviews
|
webview,
|
||||||
//
|
extensionUri: this._extensionUri,
|
||||||
// Logo geometry matches OpenChamberLogo.tsx:
|
workspaceFolder,
|
||||||
// edge=48, cos30=0.866, sin30=0.5, centerY=50
|
initialStatus,
|
||||||
// top=(50, 2), left=(8.432, 26), right=(91.568, 26), center=(50, 50)
|
cliAvailable,
|
||||||
// bottomLeft=(8.432, 74), bottomRight=(91.568, 74), bottom=(50, 98)
|
|
||||||
// topFaceCenterY = (2 + 26 + 50 + 26) / 4 = 26
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
|
|
||||||
<style>
|
|
||||||
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
|
||||||
font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
|
||||||
color: var(--vscode-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Initial loading screen styles - uses VS Code theme variables */
|
|
||||||
#initial-loading {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
z-index: 9999;
|
|
||||||
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
|
||||||
transition: opacity 0.3s ease-out;
|
|
||||||
}
|
|
||||||
#initial-loading.fade-out {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
/* Logo colors use VS Code foreground color */
|
|
||||||
#initial-loading .logo-stroke {
|
|
||||||
stroke: var(--vscode-foreground);
|
|
||||||
}
|
|
||||||
#initial-loading .logo-fill {
|
|
||||||
fill: var(--vscode-foreground);
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
#initial-loading .logo-fill-solid {
|
|
||||||
fill: var(--vscode-foreground);
|
|
||||||
}
|
|
||||||
#initial-loading .logo-fill-dim {
|
|
||||||
fill: var(--vscode-foreground);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
/* Animation on inner logo only, like OpenChamberLogo.tsx */
|
|
||||||
#initial-loading .logo-inner {
|
|
||||||
animation: logoPulse 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
#initial-loading .status-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--vscode-descriptionForeground, var(--vscode-foreground));
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#initial-loading .error-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--vscode-errorForeground, #f48771);
|
|
||||||
text-align: center;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
@keyframes logoPulse {
|
|
||||||
0%, 100% { opacity: 0.4; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<title>OpenChamber</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Initial loading screen with simplified OpenChamber logo -->
|
|
||||||
<div id="initial-loading">
|
|
||||||
<svg class="logo" width="70" height="70" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Left face -->
|
|
||||||
<path class="logo-fill logo-stroke" d="M50 50 L8.432 26 L8.432 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
<!-- Right face -->
|
|
||||||
<path class="logo-fill logo-stroke" d="M50 50 L91.568 26 L91.568 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
<!-- Top face (no fill, stroke only) -->
|
|
||||||
<path class="logo-stroke" d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- OpenCode logo on top face with pulse animation -->
|
|
||||||
<g class="logo-inner" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
|
||||||
<path class="logo-fill-solid" fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z"/>
|
|
||||||
<path class="logo-fill-dim" d="M-8 -4 L8 -4 L8 12 L-8 12 Z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<div class="status-text" id="loading-status">
|
|
||||||
${initialStatus === 'connecting' ? 'Starting OpenCode API…' : initialStatus === 'connected' ? 'Initializing…' : 'Connecting…'}
|
|
||||||
</div>
|
|
||||||
${!cliAvailable ? `<div class="error-text">OpenCode CLI not found. Please install it first.</div>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="root"></div>
|
|
||||||
<script>
|
|
||||||
// Polyfill process for Node.js modules running in browser
|
|
||||||
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
|
||||||
|
|
||||||
window.__VSCODE_CONFIG__ = {
|
|
||||||
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
|
||||||
theme: "${themeKind}",
|
|
||||||
connectionStatus: "${initialStatus}",
|
|
||||||
cliAvailable: ${cliAvailable}
|
|
||||||
};
|
|
||||||
window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}";
|
|
||||||
|
|
||||||
// Handle connection status updates to update loading screen
|
|
||||||
window.addEventListener('message', function(event) {
|
|
||||||
var msg = event.data;
|
|
||||||
if (msg && msg.type === 'connectionStatus') {
|
|
||||||
var statusEl = document.getElementById('loading-status');
|
|
||||||
if (statusEl) {
|
|
||||||
if (msg.status === 'connecting') {
|
|
||||||
statusEl.textContent = 'Starting OpenCode API…';
|
|
||||||
statusEl.classList.remove('error-text');
|
|
||||||
} else if (msg.status === 'connected') {
|
|
||||||
statusEl.textContent = 'Connected!';
|
|
||||||
statusEl.classList.remove('error-text');
|
|
||||||
} else if (msg.status === 'error') {
|
|
||||||
statusEl.textContent = msg.error || 'Connection error';
|
|
||||||
statusEl.classList.add('error-text');
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = 'Reconnecting…';
|
|
||||||
statusEl.classList.remove('error-text');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
<script type="module" src="${scriptUri}"></script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1048,6 +1048,20 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vscode:command': {
|
||||||
|
const { command, args } = (payload || {}) as { command?: string; args?: unknown[] };
|
||||||
|
if (!command) {
|
||||||
|
return { id, type, success: false, error: 'Command is required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await vscode.commands.executeCommand(command, ...(args || []));
|
||||||
|
return { id, type, success: true, data: { result } };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============== Git Operations ==============
|
// ============== Git Operations ==============
|
||||||
|
|
||||||
case 'api:git/check': {
|
case 'api:git/check': {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { ChatViewProvider } from './ChatViewProvider';
|
import { ChatViewProvider } from './ChatViewProvider';
|
||||||
|
import { AgentManagerPanelProvider } from './AgentManagerPanelProvider';
|
||||||
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||||
|
|
||||||
let chatViewProvider: ChatViewProvider | undefined;
|
let chatViewProvider: ChatViewProvider | undefined;
|
||||||
|
let agentManagerProvider: AgentManagerPanelProvider | undefined;
|
||||||
let openCodeManager: OpenCodeManager | undefined;
|
let openCodeManager: OpenCodeManager | undefined;
|
||||||
let outputChannel: vscode.OutputChannel | undefined;
|
let outputChannel: vscode.OutputChannel | undefined;
|
||||||
|
|
||||||
@@ -144,6 +146,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
void maybeMoveChatToRightSidebarOnStartup();
|
void maybeMoveChatToRightSidebarOnStartup();
|
||||||
|
|
||||||
|
// Create Agent Manager panel provider
|
||||||
|
agentManagerProvider = new AgentManagerPanelProvider(context, context.extensionUri, openCodeManager);
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand('openchamber.openAgentManager', () => {
|
||||||
|
agentManagerProvider?.createOrShow();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand('openchamber.restartApi', async () => {
|
vscode.commands.registerCommand('openchamber.restartApi', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -415,6 +426,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||||
chatViewProvider?.updateTheme(theme.kind);
|
chatViewProvider?.updateTheme(theme.kind);
|
||||||
|
agentManagerProvider?.updateTheme(theme.kind);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -429,6 +441,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
event.affectsConfiguration('workbench.preferredDarkColorTheme')
|
event.affectsConfiguration('workbench.preferredDarkColorTheme')
|
||||||
) {
|
) {
|
||||||
chatViewProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
chatViewProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
||||||
|
agentManagerProvider?.updateTheme(vscode.window.activeColorTheme.kind);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -437,6 +450,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
openCodeManager.onStatusChange((status, error) => {
|
openCodeManager.onStatusChange((status, error) => {
|
||||||
chatViewProvider?.updateConnectionStatus(status, error);
|
chatViewProvider?.updateConnectionStatus(status, error);
|
||||||
|
agentManagerProvider?.updateConnectionStatus(status, error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -449,6 +463,7 @@ export async function deactivate() {
|
|||||||
await openCodeManager?.stop();
|
await openCodeManager?.stop();
|
||||||
openCodeManager = undefined;
|
openCodeManager = undefined;
|
||||||
chatViewProvider = undefined;
|
chatViewProvider = undefined;
|
||||||
|
agentManagerProvider = undefined;
|
||||||
outputChannel?.dispose();
|
outputChannel?.dispose();
|
||||||
outputChannel = undefined;
|
outputChannel = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { getThemeKindName } from './theme';
|
||||||
|
import type { ConnectionStatus } from './opencode';
|
||||||
|
|
||||||
|
export type PanelType = 'chat' | 'agentManager';
|
||||||
|
|
||||||
|
export interface WebviewHtmlOptions {
|
||||||
|
webview: vscode.Webview;
|
||||||
|
extensionUri: vscode.Uri;
|
||||||
|
workspaceFolder: string;
|
||||||
|
initialStatus: ConnectionStatus;
|
||||||
|
cliAvailable: boolean;
|
||||||
|
panelType?: PanelType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||||
|
const { webview, extensionUri, workspaceFolder, initialStatus, cliAvailable, panelType = 'chat' } = options;
|
||||||
|
|
||||||
|
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||||
|
const scriptUri = webview.asWebviewUri(scriptPath);
|
||||||
|
|
||||||
|
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||||
|
|
||||||
|
// Use VS Code CSS variables for proper theme integration
|
||||||
|
// These variables are automatically provided by VS Code to webviews
|
||||||
|
//
|
||||||
|
// Logo geometry matches OpenChamberLogo.tsx:
|
||||||
|
// edge=48, cos30=0.866, sin30=0.5, centerY=50
|
||||||
|
// top=(50, 2), left=(8.432, 26), right=(91.568, 26), center=(50, 50)
|
||||||
|
// bottomLeft=(8.432, 74), bottomRight=(91.568, 74), bottom=(50, 98)
|
||||||
|
// topFaceCenterY = (2 + 26 + 50 + 26) / 4 = 26
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
|
||||||
|
<style>
|
||||||
|
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
||||||
|
font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initial loading screen styles - uses VS Code theme variables */
|
||||||
|
#initial-loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--vscode-editor-background, var(--vscode-sideBar-background));
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
#initial-loading.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Logo colors use VS Code foreground color */
|
||||||
|
#initial-loading .logo-stroke {
|
||||||
|
stroke: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
#initial-loading .logo-fill {
|
||||||
|
fill: var(--vscode-foreground);
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
#initial-loading .logo-fill-solid {
|
||||||
|
fill: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
#initial-loading .logo-fill-dim {
|
||||||
|
fill: var(--vscode-foreground);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
/* Animation on inner logo only, like OpenChamberLogo.tsx */
|
||||||
|
#initial-loading .logo-inner {
|
||||||
|
animation: logoPulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#initial-loading .status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--vscode-descriptionForeground, var(--vscode-foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#initial-loading .error-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
@keyframes logoPulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>OpenChamber</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Initial loading screen with simplified OpenChamber logo -->
|
||||||
|
<div id="initial-loading">
|
||||||
|
<svg class="logo" width="70" height="70" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Left face -->
|
||||||
|
<path class="logo-fill logo-stroke" d="M50 50 L8.432 26 L8.432 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<!-- Right face -->
|
||||||
|
<path class="logo-fill logo-stroke" d="M50 50 L91.568 26 L91.568 74 L50 98 Z" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<!-- Top face (no fill, stroke only) -->
|
||||||
|
<path class="logo-stroke" d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- OpenCode logo on top face with pulse animation -->
|
||||||
|
<g class="logo-inner" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
||||||
|
<path class="logo-fill-solid" fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z"/>
|
||||||
|
<path class="logo-fill-dim" d="M-8 -4 L8 -4 L8 12 L-8 12 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="status-text" id="loading-status">
|
||||||
|
${initialStatus === 'connecting' ? 'Starting OpenCode API…' : initialStatus === 'connected' ? 'Initializing…' : 'Connecting…'}
|
||||||
|
</div>
|
||||||
|
${!cliAvailable ? `<div class="error-text">OpenCode CLI not found. Please install it first.</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
// Polyfill process for Node.js modules running in browser
|
||||||
|
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
||||||
|
|
||||||
|
window.__VSCODE_CONFIG__ = {
|
||||||
|
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
||||||
|
theme: "${themeKind}",
|
||||||
|
connectionStatus: "${initialStatus}",
|
||||||
|
cliAvailable: ${cliAvailable},
|
||||||
|
panelType: "${panelType}"
|
||||||
|
};
|
||||||
|
window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}";
|
||||||
|
|
||||||
|
// Handle connection status updates to update loading screen
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
var msg = event.data;
|
||||||
|
if (msg && msg.type === 'connectionStatus') {
|
||||||
|
var statusEl = document.getElementById('loading-status');
|
||||||
|
if (statusEl) {
|
||||||
|
if (msg.status === 'connecting') {
|
||||||
|
statusEl.textContent = 'Starting OpenCode API…';
|
||||||
|
statusEl.classList.remove('error-text');
|
||||||
|
} else if (msg.status === 'connected') {
|
||||||
|
statusEl.textContent = 'Connected!';
|
||||||
|
statusEl.classList.remove('error-text');
|
||||||
|
} else if (msg.status === 'error') {
|
||||||
|
statusEl.textContent = msg.error || 'Connection error';
|
||||||
|
statusEl.classList.add('error-text');
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Reconnecting…';
|
||||||
|
statusEl.classList.remove('error-text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script type="module" src="${scriptUri}"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -125,6 +125,10 @@ export async function stopSseProxy(options: { streamId: string }): Promise<{ sto
|
|||||||
return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options);
|
return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function executeVSCodeCommand(command: string, args?: unknown[]): Promise<{ result?: unknown }> {
|
||||||
|
return sendBridgeMessage<{ result?: unknown }>('vscode:command', { command, args });
|
||||||
|
}
|
||||||
|
|
||||||
type CommandHandler = (payload: unknown) => void;
|
type CommandHandler = (payload: unknown) => void;
|
||||||
const commandHandlers = new Map<string, CommandHandler>();
|
const commandHandlers = new Map<string, CommandHandler>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createVSCodePermissionsAPI } from './permissions';
|
|||||||
import { createVSCodeToolsAPI } from './tools';
|
import { createVSCodeToolsAPI } from './tools';
|
||||||
import { createVSCodeEditorAPI } from './editor';
|
import { createVSCodeEditorAPI } from './editor';
|
||||||
import { createVSCodeGitAPI } from './git';
|
import { createVSCodeGitAPI } from './git';
|
||||||
|
import { createVSCodeActionsAPI } from './vscode';
|
||||||
|
|
||||||
// Stub APIs return sensible defaults instead of throwing
|
// Stub APIs return sensible defaults instead of throwing
|
||||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||||
@@ -30,4 +31,5 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
|||||||
notifications: createStubNotificationsAPI(),
|
notifications: createStubNotificationsAPI(),
|
||||||
tools: createVSCodeToolsAPI(),
|
tools: createVSCodeToolsAPI(),
|
||||||
editor: createVSCodeEditorAPI(),
|
editor: createVSCodeEditorAPI(),
|
||||||
|
vscode: createVSCodeActionsAPI(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { VSCodeAPI } from '../../../ui/src/lib/api/types';
|
||||||
|
import { executeVSCodeCommand } from './bridge';
|
||||||
|
|
||||||
|
export const createVSCodeActionsAPI = (): VSCodeAPI => ({
|
||||||
|
async executeCommand(command: string, ...args: unknown[]): Promise<unknown> {
|
||||||
|
const result = await executeVSCodeCommand(command, args);
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async openAgentManager(): Promise<void> {
|
||||||
|
await executeVSCodeCommand('openchamber.openAgentManager');
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../ui/src/lib/theme/vscode/adapter';
|
} from '../../ui/src/lib/theme/vscode/adapter';
|
||||||
|
|
||||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||||
|
type PanelType = 'chat' | 'agentManager';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -19,11 +20,13 @@ declare global {
|
|||||||
theme: string;
|
theme: string;
|
||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
cliAvailable?: boolean;
|
cliAvailable?: boolean;
|
||||||
|
panelType?: PanelType;
|
||||||
};
|
};
|
||||||
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
||||||
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
|
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
|
||||||
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
|
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
|
||||||
__OPENCHAMBER_HOME__?: string;
|
__OPENCHAMBER_HOME__?: string;
|
||||||
|
__OPENCHAMBER_PANEL_TYPE__?: PanelType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +43,9 @@ const bootstrapConnectionStatus = () => {
|
|||||||
|
|
||||||
bootstrapConnectionStatus();
|
bootstrapConnectionStatus();
|
||||||
|
|
||||||
|
// Expose panel type globally for App.tsx to conditionally render
|
||||||
|
window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat';
|
||||||
|
|
||||||
const handleConnectionMessage = (event: MessageEvent) => {
|
const handleConnectionMessage = (event: MessageEvent) => {
|
||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (msg?.type === 'connectionStatus') {
|
if (msg?.type === 'connectionStatus') {
|
||||||
|
|||||||
Reference in New Issue
Block a user