feat: move default agent configuration to Agent Settings (vibe-kanban) (#1453)

This commit is contained in:
Gabriel Gordon-Hall
2025-12-08 15:25:49 +00:00
committed by GitHub
parent e28e25720a
commit 45d0359118
7 changed files with 232 additions and 165 deletions

View File

@@ -40,8 +40,8 @@
} }
}, },
"taskExecution": { "taskExecution": {
"title": "Task Execution", "title": "Default Coding Agent",
"description": "Configure how tasks are executed and processed.", "description": "Choose the default coding agent for tasks.",
"executor": { "executor": {
"label": "Default Agent Configuration", "label": "Default Agent Configuration",
"placeholder": "Select profile", "placeholder": "Select profile",

View File

@@ -40,8 +40,8 @@
} }
}, },
"taskExecution": { "taskExecution": {
"title": "Ejecución de Tareas", "title": "Agente de Código Predeterminado",
"description": "Configura cómo se ejecutan y procesan las tareas.", "description": "Elige el agente de código predeterminado para las tareas.",
"executor": { "executor": {
"label": "Configuración predeterminada del Agente", "label": "Configuración predeterminada del Agente",
"placeholder": "Seleccionar perfil", "placeholder": "Seleccionar perfil",

View File

@@ -40,8 +40,8 @@
} }
}, },
"taskExecution": { "taskExecution": {
"title": "タスク実行", "title": "デフォルトコーディングエージェント",
"description": "タスクの実行と処理方法を設定します。", "description": "タスクのデフォルトコーディングエージェントを選択します。",
"executor": { "executor": {
"label": "デフォルトエージェント設定", "label": "デフォルトエージェント設定",
"placeholder": "プロファイルを選択", "placeholder": "プロファイルを選択",

View File

@@ -40,8 +40,8 @@
} }
}, },
"taskExecution": { "taskExecution": {
"title": "작업 실행", "title": "기본 코딩 에이전트",
"description": "작업이 실행되고 처리되는 방식을 구성하세요.", "description": "작업의 기본 코딩 에이전트를 선택하세요.",
"executor": { "executor": {
"label": "기본 에이전트 구성", "label": "기본 에이전트 구성",
"placeholder": "프로필 선택", "placeholder": "프로필 선택",

View File

@@ -40,8 +40,8 @@
} }
}, },
"taskExecution": { "taskExecution": {
"title": "任务执行", "title": "默认编码代理",
"description": "配置任务的执行和处理方式。", "description": "选择任务的默认编码代理。",
"executor": { "executor": {
"label": "默认代理配置", "label": "默认代理配置",
"placeholder": "选择配置文件", "placeholder": "选择配置文件",

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cloneDeep, isEqual } from 'lodash';
import { import {
Card, Card,
CardContent, CardContent,
@@ -15,23 +16,35 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { JSONEditor } from '@/components/ui/json-editor'; import { JSONEditor } from '@/components/ui/json-editor';
import { Loader2 } from 'lucide-react'; import { ChevronDown, Loader2 } from 'lucide-react';
import { ExecutorConfigForm } from '@/components/ExecutorConfigForm'; import { ExecutorConfigForm } from '@/components/ExecutorConfigForm';
import { useProfiles } from '@/hooks/useProfiles'; import { useProfiles } from '@/hooks/useProfiles';
import { useUserSystem } from '@/components/ConfigProvider'; import { useUserSystem } from '@/components/ConfigProvider';
import { CreateConfigurationDialog } from '@/components/dialogs/settings/CreateConfigurationDialog'; import { CreateConfigurationDialog } from '@/components/dialogs/settings/CreateConfigurationDialog';
import { DeleteConfigurationDialog } from '@/components/dialogs/settings/DeleteConfigurationDialog'; import { DeleteConfigurationDialog } from '@/components/dialogs/settings/DeleteConfigurationDialog';
import type { BaseCodingAgent, ExecutorConfigs } from 'shared/types'; import { useAgentAvailability } from '@/hooks/useAgentAvailability';
import { AgentAvailabilityIndicator } from '@/components/AgentAvailabilityIndicator';
import type {
BaseCodingAgent,
ExecutorConfigs,
ExecutorProfileId,
} from 'shared/types';
type ExecutorsMap = Record<string, Record<string, Record<string, unknown>>>; type ExecutorsMap = Record<string, Record<string, Record<string, unknown>>>;
export function AgentSettings() { export function AgentSettings() {
const { t } = useTranslation('settings'); const { t } = useTranslation(['settings', 'common']);
// Use profiles hook for server state // Use profiles hook for server state
const { const {
profilesContent: serverProfilesContent, profilesContent: serverProfilesContent,
@@ -42,7 +55,8 @@ export function AgentSettings() {
save: saveProfiles, save: saveProfiles,
} = useProfiles(); } = useProfiles();
const { reloadSystem } = useUserSystem(); const { config, updateAndSaveConfig, profiles, reloadSystem } =
useUserSystem();
// Local editor state (draft that may differ from server) // Local editor state (draft that may differ from server)
const [localProfilesContent, setLocalProfilesContent] = useState(''); const [localProfilesContent, setLocalProfilesContent] = useState('');
@@ -59,6 +73,17 @@ export function AgentSettings() {
useState<ExecutorConfigs | null>(null); useState<ExecutorConfigs | null>(null);
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
// Default executor profile state
const [executorDraft, setExecutorDraft] = useState<ExecutorProfileId | null>(
() => (config?.executor_profile ? cloneDeep(config.executor_profile) : null)
);
const [executorSaving, setExecutorSaving] = useState(false);
const [executorSuccess, setExecutorSuccess] = useState(false);
const [executorError, setExecutorError] = useState<string | null>(null);
// Check agent availability when draft executor changes
const agentAvailability = useAgentAvailability(executorDraft?.executor);
// Sync server state to local state when not dirty // Sync server state to local state when not dirty
useEffect(() => { useEffect(() => {
if (!isDirty && serverProfilesContent) { if (!isDirty && serverProfilesContent) {
@@ -74,6 +99,50 @@ export function AgentSettings() {
} }
}, [serverProfilesContent, isDirty]); }, [serverProfilesContent, isDirty]);
// Check if executor draft differs from saved config
const executorDirty =
executorDraft && config?.executor_profile
? !isEqual(executorDraft, config.executor_profile)
: false;
// Sync executor draft when config changes (only if not dirty)
useEffect(() => {
if (config?.executor_profile) {
setExecutorDraft((currentDraft) => {
// Only update if draft matches the old config (not dirty)
if (!currentDraft || isEqual(currentDraft, config.executor_profile)) {
return cloneDeep(config.executor_profile);
}
return currentDraft;
});
}
}, [config?.executor_profile]);
// Update executor draft
const updateExecutorDraft = (newProfile: ExecutorProfileId) => {
setExecutorDraft(newProfile);
};
// Save executor profile
const handleSaveExecutorProfile = async () => {
if (!executorDraft || !config) return;
setExecutorSaving(true);
setExecutorError(null);
try {
await updateAndSaveConfig({ executor_profile: executorDraft });
setExecutorSuccess(true);
setTimeout(() => setExecutorSuccess(false), 3000);
reloadSystem();
} catch (err) {
setExecutorError(t('settings.general.save.error'));
console.error('Error saving executor profile:', err);
} finally {
setExecutorSaving(false);
}
};
// Sync raw profiles with parsed profiles // Sync raw profiles with parsed profiles
const syncRawProfiles = (profiles: unknown) => { const syncRawProfiles = (profiles: unknown) => {
setLocalProfilesContent(JSON.stringify(profiles, null, 2)); setLocalProfilesContent(JSON.stringify(profiles, null, 2));
@@ -382,6 +451,153 @@ export function AgentSettings() {
</Alert> </Alert>
)} )}
{executorError && (
<Alert variant="destructive">
<AlertDescription>{executorError}</AlertDescription>
</Alert>
)}
{executorSuccess && (
<Alert variant="success">
<AlertDescription className="font-medium">
{t('settings.general.save.success')}
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>{t('settings.general.taskExecution.title')}</CardTitle>
<CardDescription>
{t('settings.general.taskExecution.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">
{t('settings.general.taskExecution.executor.label')}
</Label>
<div className="grid grid-cols-2 gap-2">
<Select
value={executorDraft?.executor ?? ''}
onValueChange={(value: string) => {
const variants = profiles?.[value];
const keepCurrentVariant =
variants &&
executorDraft?.variant &&
variants[executorDraft.variant];
const newProfile: ExecutorProfileId = {
executor: value as BaseCodingAgent,
variant: keepCurrentVariant ? executorDraft!.variant : null,
};
updateExecutorDraft(newProfile);
}}
disabled={!profiles}
>
<SelectTrigger id="executor">
<SelectValue
placeholder={t(
'settings.general.taskExecution.executor.placeholder'
)}
/>
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Show variant selector if selected profile has variants */}
{(() => {
const currentProfileVariant = executorDraft;
const selectedProfile =
profiles?.[currentProfileVariant?.executor || ''];
const hasVariants =
selectedProfile && Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{currentProfileVariant?.variant ||
t('settings.general.taskExecution.defaultLabel')}
</span>
<ChevronDown className="h-4 w-4 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(selectedProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => {
const newProfile: ExecutorProfileId = {
executor: currentProfileVariant!.executor,
variant: variantLabel,
};
updateExecutorDraft(newProfile);
}}
className={
currentProfileVariant?.variant === variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);
} else if (selectedProfile) {
// Show disabled button when profile exists but has no variants
return (
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
disabled
>
<span className="text-sm truncate flex-1 text-left">
{t('settings.general.taskExecution.defaultLabel')}
</span>
</Button>
);
}
return null;
})()}
</div>
<AgentAvailabilityIndicator availability={agentAvailability} />
<p className="text-sm text-muted-foreground">
{t('settings.general.taskExecution.executor.helper')}
</p>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveExecutorProfile}
disabled={!executorDirty || executorSaving}
>
{executorSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('common:buttons.save')}
</Button>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.agents.title')}</CardTitle> <CardTitle>{t('settings.agents.title')}</CardTitle>

View File

@@ -16,32 +16,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDown, Loader2, Volume2 } from 'lucide-react'; import { Loader2, Volume2 } from 'lucide-react';
import { import { EditorType, SoundFile, ThemeMode, UiLanguage } from 'shared/types';
BaseCodingAgent,
EditorType,
ExecutorProfileId,
SoundFile,
ThemeMode,
UiLanguage,
} from 'shared/types';
import { getLanguageOptions } from '@/i18n/languages'; import { getLanguageOptions } from '@/i18n/languages';
import { toPrettyCase } from '@/utils/string'; import { toPrettyCase } from '@/utils/string';
import { useEditorAvailability } from '@/hooks/useEditorAvailability'; import { useEditorAvailability } from '@/hooks/useEditorAvailability';
import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator'; import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator';
import { useAgentAvailability } from '@/hooks/useAgentAvailability';
import { AgentAvailabilityIndicator } from '@/components/AgentAvailabilityIndicator';
import { useTheme } from '@/components/ThemeProvider'; import { useTheme } from '@/components/ThemeProvider';
import { useUserSystem } from '@/components/ConfigProvider'; import { useUserSystem } from '@/components/ConfigProvider';
import { TagManager } from '@/components/TagManager'; import { TagManager } from '@/components/TagManager';
@@ -60,7 +45,6 @@ export function GeneralSettings() {
config, config,
loading, loading,
updateAndSaveConfig, // Use this on Save updateAndSaveConfig, // Use this on Save
profiles,
} = useUserSystem(); } = useUserSystem();
// Draft state management // Draft state management
@@ -77,11 +61,6 @@ export function GeneralSettings() {
// Check editor availability when draft editor changes // Check editor availability when draft editor changes
const editorAvailability = useEditorAvailability(draft?.editor.editor_type); const editorAvailability = useEditorAvailability(draft?.editor.editor_type);
// Check agent availability when draft executor changes
const agentAvailability = useAgentAvailability(
draft?.executor_profile?.executor
);
const validateBranchPrefix = useCallback( const validateBranchPrefix = useCallback(
(prefix: string): string | null => { (prefix: string): string | null => {
if (!prefix) return null; // empty allowed if (!prefix) return null; // empty allowed
@@ -299,134 +278,6 @@ export function GeneralSettings() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>{t('settings.general.taskExecution.title')}</CardTitle>
<CardDescription>
{t('settings.general.taskExecution.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">
{t('settings.general.taskExecution.executor.label')}
</Label>
<div className="grid grid-cols-2 gap-2">
<Select
value={draft?.executor_profile?.executor ?? ''}
onValueChange={(value: string) => {
const variants = profiles?.[value];
const keepCurrentVariant =
variants &&
draft?.executor_profile?.variant &&
variants[draft.executor_profile.variant];
const newProfile: ExecutorProfileId = {
executor: value as BaseCodingAgent,
variant: keepCurrentVariant
? draft!.executor_profile!.variant
: null,
};
updateDraft({
executor_profile: newProfile,
});
}}
disabled={!profiles}
>
<SelectTrigger id="executor">
<SelectValue
placeholder={t(
'settings.general.taskExecution.executor.placeholder'
)}
/>
</SelectTrigger>
<SelectContent>
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Show variant selector if selected profile has variants */}
{(() => {
const currentProfileVariant = draft?.executor_profile;
const selectedProfile =
profiles?.[currentProfileVariant?.executor || ''];
const hasVariants =
selectedProfile && Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{currentProfileVariant?.variant ||
t('settings.general.taskExecution.defaultLabel')}
</span>
<ChevronDown className="h-4 w-4 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{Object.entries(selectedProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => {
const newProfile: ExecutorProfileId = {
executor: currentProfileVariant!.executor,
variant: variantLabel,
};
updateDraft({
executor_profile: newProfile,
});
}}
className={
currentProfileVariant?.variant === variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);
} else if (selectedProfile) {
// Show disabled button when profile exists but has no variants
return (
<Button
variant="outline"
className="w-full h-10 px-2 flex items-center justify-between"
disabled
>
<span className="text-sm truncate flex-1 text-left">
{t('settings.general.taskExecution.defaultLabel')}
</span>
</Button>
);
}
return null;
})()}
</div>
<AgentAvailabilityIndicator availability={agentAvailability} />
<p className="text-sm text-muted-foreground">
{t('settings.general.taskExecution.executor.helper')}
</p>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('settings.general.editor.title')}</CardTitle> <CardTitle>{t('settings.general.editor.title')}</CardTitle>