Profile changes (#596)

* Make variants an object rather than array (vibe-kanban 63213864)

Profile variants should be an object, with key used instead of the current label.

The code should be refactored leaving no legacy trace.

crates/server/src/routes/config.rs
crates/executors/src/profile.rs

* Make variants an object rather than array (vibe-kanban 63213864)

Profile variants should be an object, with key used instead of the current label.

The code should be refactored leaving no legacy trace.

crates/server/src/routes/config.rs
crates/executors/src/profile.rs

* Remove the command builder from profiles (vibe-kanban d30abc92)

It should no longer be possible to customise the command builder in profiles.json.

Instead, anywhere where the command is customised, the code should be hardcoded as an enum field on the executor, eg claude code vs claude code router on the claude code struct.

crates/executors/src/profile.rs
crates/executors/src/executors/claude.rs

* fmt

* Refactor Qwen log normalization (vibe-kanban 076fdb3f)

Qwen basically uses the same log normalization as Gemini, can you refactor the code to make it more reusable.

A similar example exists in Amp, where we use Claude's log normalization.

crates/executors/src/executors/amp.rs
crates/executors/src/executors/qwen.rs
crates/executors/src/executors/claude.rs

* Add field overrides to executors (vibe-kanban cc3323a4)

We should add optional fields 'base_command_override' (String) and 'additional_params' (Vec<String>) to each executor, and wire these fields up to the command builder

* Update profiles (vibe-kanban e7545ab6)

Redesign the profile configuration storage system to store only differences from defaults instead of complete profile files. Implement partial profile functions (create_partial_profile, load_from_partials, save_as_diffs) that save human-readable partial profiles containing only changed values. Update ProfileConfigs::load() to handle the new partial format while maintaining backward compatibility with legacy full profile formats through automatic migration that creates backups. Implement smart variants handling that only stores changed, added, or removed variants rather than entire arrays. Fix the profile API consistency issue by replacing the manual file loading logic in the get_profiles() endpoint in crates/server/src/routes/config.rs with ProfileConfigs::get_cached() to ensure the GET endpoint uses the same cached data that PUT updates. Add comprehensive test coverage for all new functionality.

* Yolo mode becomes a field (vibe-kanban d8dd02f0)

Most executors implement some variation of yolo-mode, can you make this boolean field on each executor (if supported), where the name for the field aligns with the CLI field

* Change ClaudeCodeVariant to boolean (vibe-kanban cc05956f)

Instead of an enum ClaudeCodeVariant, let's use a variable claude_code_router to determine whether to use claude_code_router's command. If the user has also supplied a base_command_override this should take precedence (also write a warning to console)

crates/executors/src/executors/claude.rs

* Remove mcp_config_path from profile config (vibe-kanban 6c1e5947)

crates/executors/src/profile.rs

* One profile per executor (vibe-kanban b0adc27e)

Currently you can define arbitrary profiles, multiple profiles per executor. Let's refactor to simplify this configuration, instead we should only be able to configure one profile per executor.

The new format should be something like:

```json
{
  "profiles": {
    "CLAUDE_CODE": {
      "default": {
        "plan": false,
        "dangerously_skip_permissions": true,
        "append_prompt": null
      },
      "plan": {
        "plan": true,
        "dangerously_skip_permissions": false,
        "append_prompt": null
      }
    }
  }
}
```

Each profile's defaults should be defined as code instead of in default_profiles.json

profile.json will now contain:
- Overrides for default configurations
- Additional user defined configurations, for executors

It is not possible to remove a default configuration entirely, just override the configuration.

The user profile.json should still be a minimal set of overrides, to make upgrading easy.

Don't worry about migration, this will be done manually.

crates/executors/default_profiles.json
crates/executors/src/profile.rs

* SCREAMING_SNAKE_CASE

* update profile.rs

* config migration

* fmt

* delete binding

* config keys

* fmt

* shared types

* Profile variants should be saved as SCREAMING_SNAKE_CASE (vibe-kanban 5c6c124c)

crates/executors/src/profile.rs save_overrides

* rename default profiles

* remove defaulted executor fields

* backwards compatability

* fix legacy variants
This commit is contained in:
Louis Knight-Webb
2025-09-01 23:33:15 +01:00
committed by GitHub
parent a10d3f0854
commit 6a7818e057
37 changed files with 1951 additions and 856 deletions

View File

@@ -22,8 +22,9 @@ import CreatePRDialog from '@/components/tasks/Toolbar/CreatePRDialog';
import { TaskDialogProvider } from '@/contexts/task-dialog-context';
import { TaskFormDialogContainer } from '@/components/tasks/TaskFormDialogContainer';
import { ProjectProvider } from '@/contexts/project-context';
import type { EditorType, ProfileVariantLabel } from 'shared/types';
import type { EditorType } from 'shared/types';
import { ThemeMode } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { configApi } from '@/lib/api';
import * as Sentry from '@sentry/react';
import { Loader } from '@/components/ui/loader';
@@ -83,7 +84,7 @@ function AppContent() {
};
const handleOnboardingComplete = async (onboardingConfig: {
profile: ProfileVariantLabel;
profile: ExecutorProfileId;
editor: { editor_type: EditorType; custom_command: string | null };
}) => {
if (!config) return;
@@ -91,7 +92,7 @@ function AppContent() {
const updatedConfig = {
...config,
onboarding_acknowledged: true,
profile: onboardingConfig.profile,
executor_profile: onboardingConfig.profile,
editor: onboardingConfig.editor,
};

View File

@@ -24,7 +24,8 @@ import {
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Sparkles, Code, ChevronDown, HandMetal } from 'lucide-react';
import { EditorType, ProfileVariantLabel } from 'shared/types';
import { EditorType } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { useUserSystem } from '@/components/config-provider';
import { toPrettyCase } from '@/utils/string';
@@ -32,14 +33,14 @@ import { toPrettyCase } from '@/utils/string';
interface OnboardingDialogProps {
open: boolean;
onComplete: (config: {
profile: ProfileVariantLabel;
profile: ExecutorProfileId;
editor: { editor_type: EditorType; custom_command: string | null };
}) => void;
}
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
const [profile, setProfile] = useState<ProfileVariantLabel>({
profile: 'claude-code',
const [profile, setProfile] = useState<ExecutorProfileId>({
executor: 'CLAUDE_CODE',
variant: null,
});
const [editorType, setEditorType] = useState<EditorType>(EditorType.VS_CODE);
@@ -84,31 +85,29 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
<Label htmlFor="profile">Default Profile</Label>
<div className="flex gap-2">
<Select
value={profile.profile}
value={profile.executor}
onValueChange={(value) =>
setProfile({ profile: value, variant: null })
setProfile({ executor: value, variant: null })
}
>
<SelectTrigger id="profile" className="flex-1">
<SelectValue placeholder="Select your preferred coding agent" />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.label} value={profile.label}>
{profile.label}
</SelectItem>
))}
{profiles &&
Object.keys(profiles).map((profile) => (
<SelectItem key={profile} value={profile}>
{profile}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Show variant selector if selected profile has variants */}
{(() => {
const selectedProfile = profiles?.find(
(p) => p.label === profile.profile
);
const selectedProfile = profiles?.[profile.executor];
const hasVariants =
selectedProfile?.variants &&
selectedProfile.variants.length > 0;
selectedProfile && Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
@@ -119,36 +118,26 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
className="w-24 px-2 flex items-center justify-between"
>
<span className="text-xs truncate flex-1 text-left">
{profile.variant || 'Default'}
{profile.variant || 'DEFAULT'}
</span>
<ChevronDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
setProfile({ ...profile, variant: null })
}
className={!profile.variant ? 'bg-accent' : ''}
>
Default
</DropdownMenuItem>
{selectedProfile.variants.map((variant) => (
{Object.keys(selectedProfile).map((variant) => (
<DropdownMenuItem
key={variant.label}
key={variant}
onClick={() =>
setProfile({
...profile,
variant: variant.label,
variant: variant,
})
}
className={
profile.variant === variant.label
? 'bg-accent'
: ''
profile.variant === variant ? 'bg-accent' : ''
}
>
{variant.label}
{variant}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -1,8 +1,8 @@
import type { ProfileVariantLabel } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { cn } from '@/lib/utils';
interface ProfileVariantBadgeProps {
profileVariant: ProfileVariantLabel | null;
profileVariant: ExecutorProfileId | null;
className?: string;
}
@@ -16,7 +16,7 @@ export function ProfileVariantBadge({
return (
<span className={cn('text-xs text-muted-foreground', className)}>
{profileVariant.profile}
{profileVariant.executor}
{profileVariant.variant && (
<>
<span className="mx-1">/</span>

View File

@@ -10,16 +10,16 @@ import {
import {
type Config,
type Environment,
type ProfileConfig,
type UserSystemInfo,
CheckTokenResponse,
} from 'shared/types';
import type { ExecutorProfile } from 'shared/types';
import { configApi, githubAuthApi } from '../lib/api';
interface UserSystemState {
config: Config | null;
environment: Environment | null;
profiles: ProfileConfig[] | null;
profiles: Record<string, ExecutorProfile> | null;
}
interface UserSystemContextType {
@@ -34,9 +34,9 @@ interface UserSystemContextType {
// System data access
environment: Environment | null;
profiles: ProfileConfig[] | null;
profiles: Record<string, ExecutorProfile> | null;
setEnvironment: (env: Environment | null) => void;
setProfiles: (profiles: ProfileConfig[] | null) => void;
setProfiles: (profiles: Record<string, ExecutorProfile> | null) => void;
// Reload system data
reloadSystem: () => Promise<void>;
@@ -58,7 +58,10 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
// Split state for performance - independent re-renders
const [config, setConfig] = useState<Config | null>(null);
const [environment, setEnvironment] = useState<Environment | null>(null);
const [profiles, setProfiles] = useState<ProfileConfig[] | null>(null);
const [profiles, setProfiles] = useState<Record<
string,
ExecutorProfile
> | null>(null);
const [loading, setLoading] = useState(true);
const [githubTokenInvalid, setGithubTokenInvalid] = useState(false);
@@ -68,7 +71,9 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
const userSystemInfo: UserSystemInfo = await configApi.getConfig();
setConfig(userSystemInfo.config);
setEnvironment(userSystemInfo.environment);
setProfiles(userSystemInfo.profiles);
setProfiles(
userSystemInfo.executors as Record<string, ExecutorProfile> | null
);
} catch (err) {
console.error('Error loading user system:', err);
} finally {
@@ -142,7 +147,9 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) {
const userSystemInfo: UserSystemInfo = await configApi.getConfig();
setConfig(userSystemInfo.config);
setEnvironment(userSystemInfo.environment);
setProfiles(userSystemInfo.profiles);
setProfiles(
userSystemInfo.executors as Record<string, ExecutorProfile> | null
);
} catch (err) {
console.error('Error reloading user system:', err);
} finally {

View File

@@ -13,6 +13,7 @@ import { ProfileVariantBadge } from '@/components/common/ProfileVariantBadge.tsx
import { useAttemptExecution } from '@/hooks';
import ProcessLogsViewer from './ProcessLogsViewer';
import type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';
import { useProcessSelection } from '@/contexts/ProcessSelectionContext';
interface ProcessesTabProps {
@@ -157,8 +158,7 @@ function ProcessesTab({ attemptId }: ProcessesTabProps) {
'CodingAgentFollowUpRequest' ? (
<ProfileVariantBadge
profileVariant={
process.executor_action.typ
.profile_variant_label
process.executor_action.typ.executor_profile_id
}
/>
) : null}

View File

@@ -4,10 +4,10 @@ import { Button } from '@/components/ui/button';
import { projectsApi } from '@/lib/api';
import type {
GitBranch,
ProfileVariantLabel,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { useAttemptExecution } from '@/hooks';
import { useTaskStopping } from '@/stores/useTaskDetailsUiStore';
@@ -94,7 +94,7 @@ function TaskDetailsToolbar({
const [branches, setBranches] = useState<GitBranch[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [selectedProfile, setSelectedProfile] =
useState<ProfileVariantLabel | null>(null);
useState<ExecutorProfileId | null>(null);
// const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>();
const { system, profiles } = useUserSystem();
@@ -143,10 +143,10 @@ function TaskDetailsToolbar({
// Set default executor from config
useEffect(() => {
if (system.config?.profile) {
setSelectedProfile(system.config.profile);
if (system.config?.executor_profile) {
setSelectedProfile(system.config.executor_profile);
}
}, [system.config?.profile]);
}, [system.config?.executor_profile]);
// Simplified - hooks handle data fetching and navigation
// const fetchTaskAttempts = useCallback(() => {

View File

@@ -61,7 +61,7 @@ export function TaskFollowUpSection({
process.executor_action?.typ.type === 'CodingAgentInitialRequest' ||
process.executor_action?.typ.type === 'CodingAgentFollowUpRequest'
) {
return process.executor_action?.typ.profile_variant_label;
return process.executor_action?.typ.executor_profile_id;
}
return undefined;
})
@@ -73,9 +73,9 @@ export function TaskFollowUpSection({
return null;
} else if (selectedAttemptProfile && profiles) {
// No processes yet, check if profile has default variant
const profile = profiles.find((p) => p.label === selectedAttemptProfile);
if (profile?.variants && profile.variants.length > 0) {
return profile.variants[0].label;
const profile = profiles?.[selectedAttemptProfile];
if (profile && Object.keys(profile).length > 0) {
return Object.keys(profile)[0];
}
}
@@ -127,7 +127,7 @@ export function TaskFollowUpSection({
]);
const currentProfile = useMemo(() => {
if (!selectedProfile || !profiles) return null;
return profiles.find((p) => p.label === selectedProfile);
return profiles?.[selectedProfile];
}, [selectedProfile, profiles]);
// Update selectedVariant when defaultFollowUpVariant changes
@@ -264,8 +264,7 @@ export function TaskFollowUpSection({
{/* Variant selector */}
{(() => {
const hasVariants =
currentProfile?.variants &&
currentProfile.variants.length > 0;
currentProfile && Object.keys(currentProfile).length > 0;
if (hasVariants) {
return (
@@ -281,33 +280,29 @@ export function TaskFollowUpSection({
)}
>
<span className="text-xs truncate flex-1 text-left">
{selectedVariant || 'Default'}
{selectedVariant || 'DEFAULT'}
</span>
<ChevronDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => setSelectedVariant(null)}
className={!selectedVariant ? 'bg-accent' : ''}
>
Default
</DropdownMenuItem>
{currentProfile.variants.map((variant) => (
<DropdownMenuItem
key={variant.label}
onClick={() =>
setSelectedVariant(variant.label)
}
className={
selectedVariant === variant.label
? 'bg-accent'
: ''
}
>
{variant.label}
</DropdownMenuItem>
))}
{Object.entries(currentProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() =>
setSelectedVariant(variantLabel)
}
className={
selectedVariant === variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -7,12 +7,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import type {
ProfileConfig,
GitBranch,
ProfileVariantLabel,
Task,
} from 'shared/types';
import type { GitBranch, Task } from 'shared/types';
import type { ExecutorProfile } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import type { TaskAttempt } from 'shared/types';
import { useAttemptCreation } from '@/hooks/useAttemptCreation';
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
@@ -33,12 +30,12 @@ type Props = {
branches: GitBranch[];
taskAttempts: TaskAttempt[];
createAttemptBranch: string | null;
selectedProfile: ProfileVariantLabel | null;
selectedProfile: ExecutorProfileId | null;
selectedBranch: string | null;
setIsInCreateAttemptMode: Dispatch<SetStateAction<boolean>>;
setCreateAttemptBranch: Dispatch<SetStateAction<string | null>>;
setSelectedProfile: Dispatch<SetStateAction<ProfileVariantLabel | null>>;
availableProfiles: ProfileConfig[] | null;
setSelectedProfile: Dispatch<SetStateAction<ExecutorProfileId | null>>;
availableProfiles: Record<string, ExecutorProfile> | null;
selectedAttempt: TaskAttempt | null;
};
@@ -67,7 +64,7 @@ function CreateAttempt({
// Create attempt logic
const actuallyCreateAttempt = useCallback(
async (profile: ProfileVariantLabel, baseBranch?: string) => {
async (profile: ExecutorProfileId, baseBranch?: string) => {
const effectiveBaseBranch = baseBranch || selectedBranch;
if (!effectiveBaseBranch) {
@@ -85,7 +82,7 @@ function CreateAttempt({
// Handler for Enter key or Start button
const onCreateNewAttempt = useCallback(
(
profile: ProfileVariantLabel,
profile: ExecutorProfileId,
baseBranch?: string,
isKeyTriggered?: boolean
) => {
@@ -201,31 +198,34 @@ function CreateAttempt({
<div className="flex items-center gap-1.5">
<Settings2 className="h-3 w-3" />
<span className="truncate">
{selectedProfile?.profile || 'Select profile'}
{selectedProfile?.executor || 'Select profile'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{availableProfiles.map((profile) => (
<DropdownMenuItem
key={profile.label}
onClick={() => {
setSelectedProfile({
profile: profile.label,
variant: null,
});
}}
className={
selectedProfile?.profile === profile.label
? 'bg-accent'
: ''
}
>
{profile.label}
</DropdownMenuItem>
))}
{availableProfiles &&
Object.entries(availableProfiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<DropdownMenuItem
key={profileKey}
onClick={() => {
setSelectedProfile({
executor: profileKey,
variant: null,
});
}}
className={
selectedProfile?.executor === profileKey
? 'bg-accent'
: ''
}
>
{profileKey}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -239,11 +239,10 @@ function CreateAttempt({
</label>
</div>
{(() => {
const currentProfile = availableProfiles?.find(
(p) => p.label === selectedProfile?.profile
);
const currentProfile =
availableProfiles?.[selectedProfile?.executor || ''];
const hasVariants =
currentProfile?.variants && currentProfile.variants.length > 0;
currentProfile && Object.keys(currentProfile).length > 0;
if (hasVariants && currentProfile) {
return (
@@ -255,43 +254,30 @@ function CreateAttempt({
className="w-full px-2 flex items-center justify-between text-xs"
>
<span className="truncate flex-1 text-left">
{selectedProfile?.variant || 'Default'}
{selectedProfile?.variant || 'DEFAULT'}
</span>
<ArrowDown className="h-3 w-3 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
<DropdownMenuItem
onClick={() => {
if (selectedProfile) {
setSelectedProfile({
...selectedProfile,
variant: null,
});
}
}}
className={!selectedProfile?.variant ? 'bg-accent' : ''}
>
Default
</DropdownMenuItem>
{currentProfile.variants.map((variant) => (
{Object.entries(currentProfile).map(([variantLabel]) => (
<DropdownMenuItem
key={variant.label}
key={variantLabel}
onClick={() => {
if (selectedProfile) {
setSelectedProfile({
...selectedProfile,
variant: variant.label,
variant: variantLabel,
});
}
}}
className={
selectedProfile?.variant === variant.label
selectedProfile?.variant === variantLabel
? 'bg-accent'
: ''
}
>
{variant.label}
{variantLabel}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { attemptsApi } from '@/lib/api';
import type { ProfileVariantLabel, TaskAttempt } from 'shared/types';
import type { TaskAttempt } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
export function useAttemptCreation(taskId: string) {
const queryClient = useQueryClient();
@@ -13,12 +14,12 @@ export function useAttemptCreation(taskId: string) {
profile,
baseBranch,
}: {
profile: ProfileVariantLabel;
profile: ExecutorProfileId;
baseBranch: string;
}) =>
attemptsApi.create({
task_id: taskId,
profile_variant_label: profile,
executor_profile_id: profile,
base_branch: baseBranch,
}),
onSuccess: (newAttempt: TaskAttempt) => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { ProfileConfig } from 'shared/types';
import type { ExecutorProfile } from 'shared/types';
// Define available keyboard shortcuts
export interface KeyboardShortcut {
@@ -273,13 +273,13 @@ export function useVariantCyclingShortcut({
setSelectedVariant,
setIsAnimating,
}: {
currentProfile: ProfileConfig | null | undefined;
currentProfile: ExecutorProfile | null | undefined;
selectedVariant: string | null;
setSelectedVariant: (variant: string | null) => void;
setIsAnimating: (animating: boolean) => void;
}) {
useEffect(() => {
if (!currentProfile?.variants || currentProfile.variants.length === 0) {
if (!currentProfile || Object.keys(currentProfile).length === 0) {
return;
}
@@ -289,8 +289,8 @@ export function useVariantCyclingShortcut({
e.preventDefault();
// Build the variant cycle: null (Default) → variant1 → variant2 → ... → null
const variants = currentProfile.variants;
const variantLabels = variants.map((v) => v.label);
const variants = currentProfile;
const variantLabels = Object.keys(variants);
const allOptions = [null, ...variantLabels];
// Find current index and cycle to next

View File

@@ -18,7 +18,8 @@ import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { JSONEditor } from '@/components/ui/json-editor';
import { Loader2 } from 'lucide-react';
import { ProfileConfig, McpConfig } from 'shared/types';
import { McpConfig } from 'shared/types';
import type { ExecutorProfile } from 'shared/types';
import { useUserSystem } from '@/components/config-provider';
import { mcpServersApi } from '../lib/api';
import { McpConfigStrategyGeneral } from '../lib/mcp-strategies';
@@ -29,32 +30,29 @@ export function McpServers() {
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [mcpLoading, setMcpLoading] = useState(true);
const [selectedProfile, setSelectedProfile] = useState<ProfileConfig | null>(
null
);
const [selectedProfile, setSelectedProfile] =
useState<ExecutorProfile | null>(null);
const [mcpApplying, setMcpApplying] = useState(false);
const [mcpConfigPath, setMcpConfigPath] = useState<string>('');
const [success, setSuccess] = useState(false);
// Initialize selected profile when config loads
useEffect(() => {
if (config?.profile && profiles && !selectedProfile) {
if (config?.executor_profile && profiles && !selectedProfile) {
// Find the current profile
const currentProfile = profiles.find(
(p) => p.label === config.profile.profile
);
const currentProfile = profiles[config.executor_profile.executor];
if (currentProfile) {
setSelectedProfile(currentProfile);
} else if (profiles.length > 0) {
} else if (Object.keys(profiles).length > 0) {
// Default to first profile if current profile not found
setSelectedProfile(profiles[0]);
setSelectedProfile(Object.values(profiles)[0]);
}
}
}, [config?.profile, profiles, selectedProfile]);
}, [config?.executor_profile, profiles, selectedProfile]);
// Load existing MCP configuration when selected profile changes
useEffect(() => {
const loadMcpServersForProfile = async (profile: ProfileConfig) => {
const loadMcpServersForProfile = async (profile: ExecutorProfile) => {
// Reset state when loading
setMcpLoading(true);
setMcpError(null);
@@ -63,8 +61,16 @@ export function McpServers() {
try {
// Load MCP servers for the selected profile/agent
// Find the key for this profile
const profileKey = profiles
? Object.keys(profiles).find((key) => profiles[key] === profile)
: null;
if (!profileKey) {
throw new Error('Profile key not found');
}
const result = await mcpServersApi.load({
profile: profile.label,
profile: profileKey,
});
// Store the McpConfig from backend
setMcpConfig(result.mcp_config);
@@ -153,9 +159,19 @@ export function McpServers() {
fullConfig
);
// Find the key for the selected profile
const selectedProfileKey = profiles
? Object.keys(profiles).find(
(key) => profiles[key] === selectedProfile
)
: null;
if (!selectedProfileKey) {
throw new Error('Selected profile key not found');
}
await mcpServersApi.save(
{
profile: selectedProfile.label,
profile: selectedProfileKey,
},
{ servers: mcpServersConfig }
);
@@ -231,9 +247,15 @@ export function McpServers() {
<div className="space-y-2">
<Label htmlFor="mcp-executor">Profile</Label>
<Select
value={selectedProfile?.label || ''}
value={
selectedProfile
? Object.keys(profiles || {}).find(
(key) => profiles![key] === selectedProfile
) || ''
: ''
}
onValueChange={(value: string) => {
const profile = profiles?.find((p) => p.label === value);
const profile = profiles?.[value];
if (profile) setSelectedProfile(profile);
}}
>
@@ -241,11 +263,14 @@ export function McpServers() {
<SelectValue placeholder="Select executor" />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.label} value={profile.label}>
{profile.label}
</SelectItem>
))}
{profiles &&
Object.entries(profiles)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([profileKey]) => (
<SelectItem key={profileKey} value={profileKey}>
{profileKey}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">

View File

@@ -26,12 +26,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { JSONEditor } from '@/components/ui/json-editor';
import { ChevronDown, Key, Loader2, Volume2 } from 'lucide-react';
import {
ThemeMode,
EditorType,
SoundFile,
ProfileVariantLabel,
} from 'shared/types';
import { ThemeMode, EditorType, SoundFile } from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { toPrettyCase } from '@/utils/string';
import { useTheme } from '@/components/theme-provider';
@@ -100,8 +96,8 @@ export function Settings() {
try {
const parsed = JSON.parse(value);
// Basic structure validation
if (!parsed.profiles || !Array.isArray(parsed.profiles)) {
setProfilesError('Invalid structure: must have a "profiles" array');
if (!parsed.executors) {
setProfilesError('Invalid structure: must have a "executors" object');
}
} catch (err) {
if (err instanceof SyntaxError) {
@@ -277,38 +273,43 @@ export function Settings() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="executor">Default Profile</Label>
<Label htmlFor="executor">Default Executor Profile</Label>
<div className="grid grid-cols-2 gap-2">
<Select
value={config.profile?.profile || ''}
value={config.executor_profile?.executor ?? ''}
onValueChange={(value: string) => {
const newProfile: ProfileVariantLabel = {
profile: value,
const newProfile: ExecutorProfileId = {
executor: value,
variant: null,
};
updateConfig({ profile: newProfile });
updateConfig({
executor_profile: newProfile,
});
}}
>
<SelectTrigger id="executor">
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.label} value={profile.label}>
{profile.label}
</SelectItem>
))}
{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 selectedProfile = profiles?.find(
(p) => p.label === config.profile?.profile
);
const currentProfileVariant = config.executor_profile;
const selectedProfile =
profiles?.[currentProfileVariant?.executor || ''];
const hasVariants =
selectedProfile?.variants &&
selectedProfile.variants.length > 0;
selectedProfile &&
Object.keys(selectedProfile).length > 0;
if (hasVariants) {
return (
@@ -319,45 +320,37 @@ export function Settings() {
className="w-full h-10 px-2 flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{config.profile?.variant || 'Default'}
{currentProfileVariant?.variant || 'DEFAULT'}
</span>
<ChevronDown className="h-4 w-4 ml-1 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
const newProfile: ProfileVariantLabel = {
profile: config.profile?.profile || '',
variant: null,
};
updateConfig({ profile: newProfile });
}}
className={
!config.profile?.variant ? 'bg-accent' : ''
}
>
Default
</DropdownMenuItem>
{selectedProfile.variants.map((variant) => (
<DropdownMenuItem
key={variant.label}
onClick={() => {
const newProfile: ProfileVariantLabel = {
profile: config.profile?.profile || '',
variant: variant.label,
};
updateConfig({ profile: newProfile });
}}
className={
config.profile?.variant === variant.label
? 'bg-accent'
: ''
}
>
{variant.label}
</DropdownMenuItem>
))}
{Object.entries(selectedProfile).map(
([variantLabel]) => (
<DropdownMenuItem
key={variantLabel}
onClick={() => {
const newProfile: ExecutorProfileId = {
executor:
currentProfileVariant?.executor || '',
variant: variantLabel,
};
updateConfig({
executor_profile: newProfile,
});
}}
className={
currentProfileVariant?.variant ===
variantLabel
? 'bg-accent'
: ''
}
>
{variantLabel}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -379,8 +372,8 @@ export function Settings() {
})()}
</div>
<p className="text-sm text-muted-foreground">
Choose the default profile to use when creating a task
attempt.
Choose the default executor profile to use when creating a
task attempt.
</p>
</div>
</CardContent>