Improve executor profile selection with save-as-default toggle and variant ordering (Vibe Kanban) (#1912)

* All implementations are complete. Here's a summary of the changes:

## Summary of Changes

### 1. `frontend/src/utils/executor.ts`
- Added `areProfilesEqual()` function to compare two `ExecutorProfileId` objects, treating `null`/`undefined` variant as equivalent to `"DEFAULT"`
- Updated `getVariantOptions()` to sort variants with DEFAULT first, then alphabetically

### 2. `frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx`
- Added `saveAsDefault` state (default: `false`)
- Added `hasChangedFromDefault` memo to detect when user has changed from their saved default
- Added `useEffect` to reset toggle when profile matches default again
- Updated `handleExecutorChange` to:
  - Use saved variant when switching TO the user's default executor
  - Fall back to DEFAULT variant otherwise
- Updated `handleSubmit` to save profile as default when toggle is checked
- Added `saveAsDefault` prop to `CreateChatBox`

### 3. `frontend/src/components/ui-new/primitives/CreateChatBox.tsx`
- Added `SaveAsDefaultProps` interface
- Added `saveAsDefault` prop to `CreateChatBoxProps`
- Rendered checkbox toggle in `headerLeft` (after executor dropdown) when `saveAsDefault.visible` is true

### 4. `frontend/src/i18n/locales/en/tasks.json`
- Added `"saveAsDefault": "Save as default"` translation key under `conversation`

* Cleanup script changes for workspace 143d0206-8a21-454b-96ad-ce53cbcfc24a

* All translations have been added:

| Language | Translation |
|----------|-------------|
| English (en) | "Save as default" |
| Spanish (es) | "Guardar como predeterminado" |
| Japanese (ja) | "デフォルトとして保存" |
| Korean (ko) | "기본값으로 저장" |
| Simplified Chinese (zh-Hans) | "设为默认" |
| Traditional Chinese (zh-Hant) | "設為預設" |
This commit is contained in:
Louis Knight-Webb
2026-01-10 12:26:17 +00:00
committed by GitHub
parent d3dc1439cc
commit 4e20df9823
9 changed files with 109 additions and 11 deletions

View File

@@ -1,17 +1,17 @@
import { useMemo, useCallback, useState } from 'react';
import { useMemo, useCallback, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateMode } from '@/contexts/CreateModeContext';
import { useUserSystem } from '@/components/ConfigProvider';
import { useCreateWorkspace } from '@/hooks/useCreateWorkspace';
import { useCreateAttachments } from '@/hooks/useCreateAttachments';
import { getVariantOptions } from '@/utils/executor';
import { getVariantOptions, areProfilesEqual } from '@/utils/executor';
import { splitMessageToTitleDescription } from '@/utils/string';
import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types';
import { CreateChatBox } from '../primitives/CreateChatBox';
export function CreateChatBoxContainer() {
const { t } = useTranslation('common');
const { profiles, config } = useUserSystem();
const { profiles, config, updateAndSaveConfig } = useUserSystem();
const {
repos,
targetBranches,
@@ -26,6 +26,7 @@ export function CreateChatBoxContainer() {
const { createWorkspace } = useCreateWorkspace();
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [saveAsDefault, setSaveAsDefault] = useState(false);
// Attachment handling - insert markdown and track image IDs
const handleInsertMarkdown = useCallback(
@@ -64,6 +65,19 @@ export function CreateChatBoxContainer() {
[effectiveProfile?.executor, profiles]
);
// Detect if user has changed from their saved default
const hasChangedFromDefault = useMemo(() => {
if (!config?.executor_profile || !effectiveProfile) return false;
return !areProfilesEqual(effectiveProfile, config.executor_profile);
}, [effectiveProfile, config?.executor_profile]);
// Reset toggle when profile matches default again
useEffect(() => {
if (!hasChangedFromDefault) {
setSaveAsDefault(false);
}
}, [hasChangedFromDefault]);
// Get project ID from context
const projectId = selectedProjectId;
@@ -86,17 +100,39 @@ export function CreateChatBoxContainer() {
[effectiveProfile, setSelectedProfile]
);
// Handle executor change - reset variant to first available
// Handle executor change - use saved variant if switching to default executor
const handleExecutorChange = useCallback(
(executor: BaseCodingAgent) => {
const executorConfig = profiles?.[executor];
const variants = executorConfig ? Object.keys(executorConfig) : [];
setSelectedProfile({
executor,
variant: variants[0] ?? null,
});
if (!executorConfig) {
setSelectedProfile({ executor, variant: null });
return;
}
const variants = Object.keys(executorConfig);
let targetVariant: string | null = null;
// If switching to user's default executor, use their saved variant
if (
config?.executor_profile?.executor === executor &&
config?.executor_profile?.variant
) {
const savedVariant = config.executor_profile.variant;
if (variants.includes(savedVariant)) {
targetVariant = savedVariant;
}
}
// Fallback to DEFAULT or first available
if (!targetVariant) {
targetVariant = variants.includes('DEFAULT')
? 'DEFAULT'
: (variants[0] ?? null);
}
setSelectedProfile({ executor, variant: targetVariant });
},
[profiles, setSelectedProfile]
[profiles, setSelectedProfile, config?.executor_profile]
);
// Handle submit
@@ -104,6 +140,11 @@ export function CreateChatBoxContainer() {
setHasAttemptedSubmit(true);
if (!canSubmit || !effectiveProfile || !projectId) return;
// Save profile as default if toggle is checked
if (saveAsDefault && hasChangedFromDefault) {
await updateAndSaveConfig({ executor_profile: effectiveProfile });
}
const { title, description } = splitMessageToTitleDescription(message);
await createWorkspace.mutateAsync({
@@ -137,6 +178,9 @@ export function CreateChatBoxContainer() {
getImageIds,
clearAttachments,
clearDraft,
saveAsDefault,
hasChangedFromDefault,
updateAndSaveConfig,
]);
// Determine error to display
@@ -194,6 +238,11 @@ export function CreateChatBoxContainer() {
}
: undefined
}
saveAsDefault={{
checked: saveAsDefault,
onChange: setSaveAsDefault,
visible: hasChangedFromDefault,
}}
error={displayError}
projectId={projectId}
agent={effectiveProfile?.executor ?? null}

View File

@@ -5,6 +5,7 @@ import { toPrettyCase } from '@/utils/string';
import type { BaseCodingAgent } from 'shared/types';
import type { LocalImageMetadata } from '@/components/ui/wysiwyg/context/task-attempt-context';
import { AgentIcon } from '@/components/agents/AgentIcon';
import { Checkbox } from '@/components/ui/checkbox';
import {
ChatBoxBase,
VisualVariant,
@@ -21,12 +22,19 @@ export interface ExecutorProps {
onChange: (executor: BaseCodingAgent) => void;
}
export interface SaveAsDefaultProps {
checked: boolean;
onChange: (checked: boolean) => void;
visible: boolean;
}
interface CreateChatBoxProps {
editor: EditorProps;
onSend: () => void;
isSending: boolean;
executor: ExecutorProps;
variant?: VariantProps;
saveAsDefault?: SaveAsDefaultProps;
error?: string | null;
projectId?: string;
agent?: BaseCodingAgent | null;
@@ -45,6 +53,7 @@ export function CreateChatBox({
isSending,
executor,
variant,
saveAsDefault,
error,
projectId,
agent,
@@ -107,6 +116,16 @@ export function CreateChatBox({
</DropdownMenuItem>
))}
</ToolbarDropdown>
{saveAsDefault?.visible && (
<label className="flex items-center gap-1.5 text-sm text-low cursor-pointer ml-2">
<Checkbox
checked={saveAsDefault.checked}
onCheckedChange={saveAsDefault.onChange}
className="h-3.5 w-3.5"
/>
<span>{t('conversation.saveAsDefault')}</span>
</label>
)}
</>
}
footerLeft={

View File

@@ -220,6 +220,7 @@
},
"conversation": {
"executors": "Executors",
"saveAsDefault": "Save as default",
"you": "You",
"thinking": "Thinking",
"todo": "Todo",

View File

@@ -623,6 +623,7 @@
"conflicts_other": "{{count}} archivos en conflicto necesitan resolución manual"
},
"executors": "Ejecutores",
"saveAsDefault": "Guardar como predeterminado",
"script": {
"clickToViewLogs": "Haz clic para ver registros",
"completedSuccessfully": "Completado exitosamente",

View File

@@ -623,6 +623,7 @@
"conflicts_other": "{{count}}件の競合ファイルを手動で解決する必要があります"
},
"executors": "エクゼキューター",
"saveAsDefault": "デフォルトとして保存",
"script": {
"clickToViewLogs": "クリックしてログを表示",
"completedSuccessfully": "正常に完了しました",

View File

@@ -623,6 +623,7 @@
"conflicts_other": "{{count}}개의 충돌 파일을 수동으로 해결해야 합니다"
},
"executors": "실행기",
"saveAsDefault": "기본값으로 저장",
"script": {
"clickToViewLogs": "로그를 보려면 클릭하세요",
"completedSuccessfully": "성공적으로 완료됨",

View File

@@ -650,6 +650,7 @@
"clickToViewLogs": "点击查看日志"
},
"executors": "执行器",
"saveAsDefault": "设为默认",
"updatedTodos": "更新的待办事项",
"viewInChangesPanel": "在更改面板中查看",
"unableToRenderDiff": "无法显示差异。"

View File

@@ -623,6 +623,7 @@
"conflicts_other": "{{count}}個衝突檔案需要手動解決"
},
"executors": "執行器",
"saveAsDefault": "設為預設",
"script": {
"clickToViewLogs": "點擊查看日誌",
"completedSuccessfully": "成功完成",

View File

@@ -6,8 +6,25 @@ import type {
ExecutionProcess,
} from 'shared/types';
/**
* Compare two ExecutorProfileIds for equality.
* Treats null/undefined variant as equivalent to "DEFAULT".
*/
export function areProfilesEqual(
a: ExecutorProfileId | null | undefined,
b: ExecutorProfileId | null | undefined
): boolean {
if (!a || !b) return a === b;
if (a.executor !== b.executor) return false;
// Normalize variants: null/undefined -> 'DEFAULT'
const variantA = a.variant ?? 'DEFAULT';
const variantB = b.variant ?? 'DEFAULT';
return variantA === variantB;
}
/**
* Get variant options for a given executor from profiles.
* Returns variants sorted: DEFAULT first, then alphabetically.
*/
export function getVariantOptions(
executor: BaseCodingAgent | null | undefined,
@@ -15,7 +32,14 @@ export function getVariantOptions(
): string[] {
if (!executor || !profiles) return [];
const executorConfig = profiles[executor];
return executorConfig ? Object.keys(executorConfig) : [];
if (!executorConfig) return [];
const variants = Object.keys(executorConfig);
return variants.sort((a, b) => {
if (a === 'DEFAULT') return -1;
if (b === 'DEFAULT') return 1;
return a.localeCompare(b);
});
}
/**