From 4e20df9823373b70ac7d244dade78a4faa4c9e8a Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Sat, 10 Jan 2026 12:26:17 +0000 Subject: [PATCH] Improve executor profile selection with save-as-default toggle and variant ordering (Vibe Kanban) (#1912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) | "設為預設" | --- .../containers/CreateChatBoxContainer.tsx | 69 ++++++++++++++++--- .../ui-new/primitives/CreateChatBox.tsx | 19 +++++ frontend/src/i18n/locales/en/tasks.json | 1 + frontend/src/i18n/locales/es/tasks.json | 1 + frontend/src/i18n/locales/ja/tasks.json | 1 + frontend/src/i18n/locales/ko/tasks.json | 1 + frontend/src/i18n/locales/zh-Hans/tasks.json | 1 + frontend/src/i18n/locales/zh-Hant/tasks.json | 1 + frontend/src/utils/executor.ts | 26 ++++++- 9 files changed, 109 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx b/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx index c95443c1..604254a1 100644 --- a/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx +++ b/frontend/src/components/ui-new/containers/CreateChatBoxContainer.tsx @@ -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} diff --git a/frontend/src/components/ui-new/primitives/CreateChatBox.tsx b/frontend/src/components/ui-new/primitives/CreateChatBox.tsx index e0026c61..b561ca64 100644 --- a/frontend/src/components/ui-new/primitives/CreateChatBox.tsx +++ b/frontend/src/components/ui-new/primitives/CreateChatBox.tsx @@ -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({ ))} + {saveAsDefault?.visible && ( + + )} } footerLeft={ diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 148e164e..b3c2c3a0 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -220,6 +220,7 @@ }, "conversation": { "executors": "Executors", + "saveAsDefault": "Save as default", "you": "You", "thinking": "Thinking", "todo": "Todo", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 594619a5..762941fb 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -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", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 49f8b833..159a145b 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -623,6 +623,7 @@ "conflicts_other": "{{count}}件の競合ファイルを手動で解決する必要があります" }, "executors": "エクゼキューター", + "saveAsDefault": "デフォルトとして保存", "script": { "clickToViewLogs": "クリックしてログを表示", "completedSuccessfully": "正常に完了しました", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index c3c9e0e8..7f4b4ad0 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -623,6 +623,7 @@ "conflicts_other": "{{count}}개의 충돌 파일을 수동으로 해결해야 합니다" }, "executors": "실행기", + "saveAsDefault": "기본값으로 저장", "script": { "clickToViewLogs": "로그를 보려면 클릭하세요", "completedSuccessfully": "성공적으로 완료됨", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index e0d48463..05695507 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -650,6 +650,7 @@ "clickToViewLogs": "点击查看日志" }, "executors": "执行器", + "saveAsDefault": "设为默认", "updatedTodos": "更新的待办事项", "viewInChangesPanel": "在更改面板中查看", "unableToRenderDiff": "无法显示差异。" diff --git a/frontend/src/i18n/locales/zh-Hant/tasks.json b/frontend/src/i18n/locales/zh-Hant/tasks.json index d9a99dfb..fe7e3505 100644 --- a/frontend/src/i18n/locales/zh-Hant/tasks.json +++ b/frontend/src/i18n/locales/zh-Hant/tasks.json @@ -623,6 +623,7 @@ "conflicts_other": "{{count}}個衝突檔案需要手動解決" }, "executors": "執行器", + "saveAsDefault": "設為預設", "script": { "clickToViewLogs": "點擊查看日誌", "completedSuccessfully": "成功完成", diff --git a/frontend/src/utils/executor.ts b/frontend/src/utils/executor.ts index cb8482ae..9382a84b 100644 --- a/frontend/src/utils/executor.ts +++ b/frontend/src/utils/executor.ts @@ -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); + }); } /**