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