From 517cf1e4693ace88ab676adbc45ea6888ed92313 Mon Sep 17 00:00:00 2001 From: Mai Tan Loi Date: Sun, 30 Aug 2026 16:54:25 +0700 Subject: [PATCH] fix(onboarding): keep the chosen agent identity when the prompt is edited Editing the description in onboarding step 3 deselected the preset, and the display name, agent key and emoji were derived from that selection. Losing it dropped them to a hardcoded "Fox Spirit" / "fox-spirit" / fox emoji, so picking the Writer preset and rewriting its prompt created an agent named Fox Spirit. The step also offered no way to name the agent at all. Identity is now editable state that a preset only prefills: - Add display name, emoji and agent key inputs to both onboarding steps, with the key auto-derived from the name until it is edited by hand. - Editing the prompt only clears the preset highlight; the identity stays. - Block submit on an empty name or an invalid slug instead of falling back. - Give the web presets stable English agent keys, so the slug no longer depends on the UI locale, and strip the emoji prefix off the preset label before it becomes a display name. - While a preset is active and its field is untouched, the name and prompt still follow a locale switch. Reuses the existing agents:create.* keys, present in en/vi/zh on both surfaces. --- .../src/components/onboarding/AgentStep.tsx | 149 +++++++++++++----- ui/web/src/pages/agents/agent-presets.ts | 64 ++++---- ui/web/src/pages/setup/step-agent.tsx | 143 ++++++++++++----- 3 files changed, 246 insertions(+), 110 deletions(-) diff --git a/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx b/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx index bb11b974..869cb98d 100644 --- a/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx +++ b/ui/desktop/frontend/src/components/onboarding/AgentStep.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { getApiClient } from '../../lib/api' +import { slugify, isValidSlug } from '../../lib/slug' import { agentService } from '../../services/agent-service' import { SummoningModal } from './SummoningModal' @@ -28,6 +29,13 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp const { t } = useTranslation(['desktop', 'agents', 'common']) const [selectedPresetIdx, setSelectedPresetIdx] = useState(0) const [description, setDescription] = useState('') + // Identity is editable state, only *prefilled* from the selected preset. It must + // survive description edits — deselecting a preset never rewrites the name/key. + const [displayName, setDisplayName] = useState('') + const [agentKey, setAgentKey] = useState('') + const [displayNameEdited, setDisplayNameEdited] = useState(false) + const [agentKeyEdited, setAgentKeyEdited] = useState(false) + const [emoji, setEmoji] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [createdAgent, setCreatedAgent] = useState<{ id: string; name: string } | null>(null) @@ -39,62 +47,87 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp return t(`${preset.ns}:presets.${preset.key}.prompt`) } - // Init description from first preset + // Label format: "🦊 Fox Spirit" — the display name drops the emoji prefix + function getPresetName(idx: number): string { + const preset = PRESET_KEYS[idx] + if (!preset) return '' + const label = t(`${preset.ns}:presets.${preset.key}.label`) as string + return label.replace(/^\S+\s+/, '').trim() || label.trim() + } + + // Init key + emoji from the first preset; name and prompt are filled by the + // locale-sync effects below. useEffect(() => { - if (!description) setDescription(getPresetPrompt(0)) - // eslint-disable-next-line react-hooks/exhaustive-deps + setAgentKey(PRESET_KEYS[0].agentKey) + setEmoji(PRESET_KEYS[0].emoji) }, []) - const displayName = useMemo(() => { - const preset = PRESET_KEYS[selectedPresetIdx] - if (preset) { - const label = t(`${preset.ns}:presets.${preset.key}.label`) as string - // Label format: "🦊 Fox Spirit" — strip emoji prefix - return label.replace(/^\S+\s*/, '') - } - return 'Fox Spirit' - }, [selectedPresetIdx, t]) + const presetPrompt = selectedPresetIdx >= 0 ? getPresetPrompt(selectedPresetIdx) : undefined + const presetName = selectedPresetIdx >= 0 ? getPresetName(selectedPresetIdx) : undefined - // Agent key: stable English slug, never translated - const agentKey = useMemo(() => { - const preset = PRESET_KEYS[selectedPresetIdx] - return preset?.agentKey ?? 'little-fox' - }, [selectedPresetIdx]) - const selectedEmoji = PRESET_KEYS[selectedPresetIdx]?.emoji ?? '🦊' - - // Sync description when preset changes + // Language-aware re-sync: while a preset is active and the field is untouched, + // follow the translated preset text. Deps are plain strings, so this only fires + // when the translation actually changes (locale switch), not on every render. useEffect(() => { - if (selectedPresetIdx >= 0) { - setDescription(getPresetPrompt(selectedPresetIdx)) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPresetIdx]) + if (presetPrompt === undefined) return + setDescription(presetPrompt) + }, [presetPrompt]) + + useEffect(() => { + if (presetName === undefined || displayNameEdited) return + setDisplayName(presetName) + }, [presetName, displayNameEdited]) + + const trimmedName = displayName.trim() + const keyValid = isValidSlug(agentKey) + const canSubmit = !!trimmedName && keyValid && !!description.trim() + + const handleSelectPreset = (idx: number) => { + const preset = PRESET_KEYS[idx] + if (!preset) return + setSelectedPresetIdx(idx) + setDescription(getPresetPrompt(idx)) + setDisplayName(getPresetName(idx)) + setDisplayNameEdited(false) + setEmoji(preset.emoji) + setAgentKey(preset.agentKey) + setAgentKeyEdited(false) + } const handleDescriptionChange = (value: string) => { setDescription(value) - if (selectedPresetIdx >= 0) { - const presetPrompt = getPresetPrompt(selectedPresetIdx) - if (value !== presetPrompt) setSelectedPresetIdx(-1) - } + // Editing the prompt only clears the preset highlight — identity stays as typed. + if (selectedPresetIdx >= 0 && value !== presetPrompt) setSelectedPresetIdx(-1) + } + + const handleDisplayNameChange = (value: string) => { + setDisplayNameEdited(true) + setDisplayName(value) + if (!agentKeyEdited) setAgentKey(slugify(value)) + } + + const handleAgentKeyChange = (value: string) => { + setAgentKeyEdited(true) + setAgentKey(value) } const handleSubmit = async () => { - if (!description.trim()) return + if (!canSubmit) return setLoading(true) setError('') try { const result = await getApiClient().post<{ id: string }>('/v1/agents', { agent_key: agentKey, - display_name: displayName.trim() || undefined, + display_name: trimmedName, provider: provider.name, model: model || '', agent_type: 'predefined', is_default: true, // Promoted fields at top level — agent_description triggers summoning on backend agent_description: description.trim() || null, - emoji: selectedEmoji || null, + emoji: emoji.trim() || null, }) - setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey }) + setCreatedAgent({ id: result.id, name: trimmedName || agentKey }) } catch (err) { setError(err instanceof Error ? err.message : t('common:failedToCreateAgent')) } finally { @@ -140,6 +173,50 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp )} + {/* Agent identity — editable name + key, prefilled from the preset */} +
+
+ +
+ setEmoji(e.target.value)} + maxLength={2} + placeholder="🤖" + title={t('agents:create.emojiHint')} + className="w-14 shrink-0 text-center w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> + handleDisplayNameChange(e.target.value)} + placeholder={t('agents:create.displayNamePlaceholder')} + className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ +
+ + handleAgentKeyChange(e.target.value)} + onBlur={(e) => setAgentKey(slugify(e.target.value))} + placeholder={t('agents:create.agentKeyPlaceholder')} + className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2.5 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +

+ {t('agents:create.agentKeyHint')} +

+
+
+ {/* Preset personality buttons */}
@@ -148,7 +225,7 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp