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

View File

@@ -5,6 +5,7 @@ import { toPrettyCase } from '@/utils/string';
import type { BaseCodingAgent } from 'shared/types'; import type { BaseCodingAgent } from 'shared/types';
import type { LocalImageMetadata } from '@/components/ui/wysiwyg/context/task-attempt-context'; import type { LocalImageMetadata } from '@/components/ui/wysiwyg/context/task-attempt-context';
import { AgentIcon } from '@/components/agents/AgentIcon'; import { AgentIcon } from '@/components/agents/AgentIcon';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
ChatBoxBase, ChatBoxBase,
VisualVariant, VisualVariant,
@@ -21,12 +22,19 @@ export interface ExecutorProps {
onChange: (executor: BaseCodingAgent) => void; onChange: (executor: BaseCodingAgent) => void;
} }
export interface SaveAsDefaultProps {
checked: boolean;
onChange: (checked: boolean) => void;
visible: boolean;
}
interface CreateChatBoxProps { interface CreateChatBoxProps {
editor: EditorProps; editor: EditorProps;
onSend: () => void; onSend: () => void;
isSending: boolean; isSending: boolean;
executor: ExecutorProps; executor: ExecutorProps;
variant?: VariantProps; variant?: VariantProps;
saveAsDefault?: SaveAsDefaultProps;
error?: string | null; error?: string | null;
projectId?: string; projectId?: string;
agent?: BaseCodingAgent | null; agent?: BaseCodingAgent | null;
@@ -45,6 +53,7 @@ export function CreateChatBox({
isSending, isSending,
executor, executor,
variant, variant,
saveAsDefault,
error, error,
projectId, projectId,
agent, agent,
@@ -107,6 +116,16 @@ export function CreateChatBox({
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</ToolbarDropdown> </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={ footerLeft={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,25 @@ import type {
ExecutionProcess, ExecutionProcess,
} from 'shared/types'; } 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. * Get variant options for a given executor from profiles.
* Returns variants sorted: DEFAULT first, then alphabetically.
*/ */
export function getVariantOptions( export function getVariantOptions(
executor: BaseCodingAgent | null | undefined, executor: BaseCodingAgent | null | undefined,
@@ -15,7 +32,14 @@ export function getVariantOptions(
): string[] { ): string[] {
if (!executor || !profiles) return []; if (!executor || !profiles) return [];
const executorConfig = profiles[executor]; 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);
});
} }
/** /**