Merge pull request #1541 from maitanloi/fix/onboarding-agent-name-slug

fix(onboarding): keep the chosen agent identity when the prompt is edited
This commit is contained in:
Clark Cant authored and GitHub committed 2026-08-30 21:13:37 +07:00
commit 753f583f24
3 files changed
+246 -110

No files matched your search

@@ -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<number>(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
)}
</div>
{/* Agent identity — editable name + key, prefilled from the preset */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label htmlFor="onboard-agent-name" className="block text-sm font-medium text-text-secondary">
{t('agents:create.displayName')}
</label>
<div className="flex gap-2">
<input
id="onboard-agent-emoji"
value={emoji}
onChange={(e) => 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"
/>
<input
id="onboard-agent-name"
value={displayName}
onChange={(e) => 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"
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="onboard-agent-key" className="block text-sm font-medium text-text-secondary">
{t('agents:create.agentKey')}
</label>
<input
id="onboard-agent-key"
value={agentKey}
onChange={(e) => 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"
/>
<p className={agentKey && !keyValid ? 'text-xs text-error' : 'text-xs text-text-muted'}>
{t('agents:create.agentKeyHint')}
</p>
</div>
</div>
{/* Preset personality buttons */}
<div className="space-y-2">
<label className="block text-sm font-medium text-text-secondary">{t('agents:detail.personality')}</label>
@@ -148,7 +225,7 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp
<button
key={preset.key}
type="button"
onClick={() => setSelectedPresetIdx(idx)}
onClick={() => handleSelectPreset(idx)}
className={[
'cursor-pointer rounded-full border px-3 py-1 text-xs transition-colors',
selectedPresetIdx === idx
@@ -185,7 +262,7 @@ export function AgentStep({ provider, model, onBack, onComplete }: AgentStepProp
</button>
<button
onClick={handleSubmit}
disabled={loading || !description.trim()}
disabled={loading || !canSubmit}
className="px-6 py-2.5 bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading && <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />}
+29 -35
View File
@@ -1,48 +1,42 @@
import { useTranslation } from "react-i18next";
export interface AgentPreset {
/** Chip label as translated, may carry a leading emoji (e.g. "🦊 Fox Spirit"). */
label: string;
/** Translated display name with the leading emoji stripped. */
name: string;
prompt: string;
emoji: string;
/** Stable English slug used as agent_key — never translated. */
agentKey: string;
}
/** Labels are stored as "<emoji> <name>"; the emoji lives in its own field. */
function stripLeadingEmoji(label: string): string {
return label.replace(/^\S+\s+/, "").trim() || label.trim();
}
export function useAgentPresets(): AgentPreset[] {
const { t } = useTranslation("agents");
const build = (key: string, emoji: string, agentKey: string): AgentPreset => {
const label = t(`presets.${key}.label`);
return {
label,
name: stripLeadingEmoji(label),
prompt: t(`presets.${key}.prompt`),
emoji,
agentKey,
};
};
return [
{
label: t("presets.foxSpirit.label"),
prompt: t("presets.foxSpirit.prompt"),
emoji: "🦊",
},
{
label: t("presets.coder.label"),
prompt: t("presets.coder.prompt"),
emoji: "💻",
},
{
label: t("presets.support.label"),
prompt: t("presets.support.prompt"),
emoji: "🎧",
},
{
label: t("presets.writer.label"),
prompt: t("presets.writer.prompt"),
emoji: "✍️",
},
{
label: t("presets.translator.label"),
prompt: t("presets.translator.prompt"),
emoji: "🌐",
},
{
label: t("presets.artisan.label"),
prompt: t("presets.artisan.prompt"),
emoji: "🎨",
},
{
label: t("presets.astrologer.label"),
prompt: t("presets.astrologer.prompt"),
emoji: "🔮",
},
build("foxSpirit", "🦊", "little-fox"),
build("coder", "💻", "dev"),
build("support", "🎧", "support"),
build("writer", "✍️", "quill"),
build("translator", "🌐", "translator"),
build("artisan", "🎨", "artisan"),
build("astrologer", "🔮", "mimi"),
];
}
+104 -39
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
@@ -12,7 +13,7 @@ import { useAgents } from "@/pages/agents/hooks/use-agents";
import { SummoningModal } from "@/pages/agents/summoning-modal";
import { useAgentPresets } from "@/pages/agents/agent-presets";
import { useWsEvent } from "@/hooks/use-ws-event";
import { slugify } from "@/lib/slug";
import { slugify, isValidSlug } from "@/lib/slug";
import { toast } from "@/stores/use-toast-store";
import type { ProviderData } from "@/types/provider";
import type { AgentData } from "@/types/agent";
@@ -27,6 +28,7 @@ interface StepAgentProps {
export function StepAgent({ provider, model, onComplete, onBack, existingAgent }: StepAgentProps) {
const { t } = useTranslation("setup");
const { t: tAgents } = useTranslation("agents");
const { createAgent, updateAgent, deleteAgent, resummonAgent, cancelSummonAgent } = useAgents();
const agentPresets = useAgentPresets();
@@ -40,6 +42,19 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
const [selectedPresetIdx, setSelectedPresetIdx] = useState<number | null>(
existingAgent ? null : 0,
);
// 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(
existingAgent?.display_name ?? defaultPreset?.name ?? "",
);
const [agentKey, setAgentKey] = useState(
existingAgent?.agent_key ?? defaultPreset?.agentKey ?? "",
);
const [displayNameEdited, setDisplayNameEdited] = useState(false);
const [agentKeyEdited, setAgentKeyEdited] = useState(false);
const [emoji, setEmoji] = useState(
existingAgent?.emoji ?? defaultPreset?.emoji ?? "",
);
const [selfEvolve, setSelfEvolve] = useState(
Boolean(existingAgent?.self_evolve),
);
@@ -57,26 +72,25 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
const [createdAgent, setCreatedAgent] = useState<{ id: string; name: string } | null>(null);
const [agentResult, setAgentResult] = useState<AgentData | null>(null);
// Derive agent key and display name from selected preset or description
const displayName = useMemo(() => {
if (existingAgent) return existingAgent.display_name ?? "";
if (selectedPresetIdx !== null && agentPresets[selectedPresetIdx]) {
return agentPresets[selectedPresetIdx].label;
}
return "Fox Spirit";
}, [existingAgent, selectedPresetIdx, agentPresets]);
const activePreset = selectedPresetIdx !== null ? agentPresets[selectedPresetIdx] : undefined;
const presetPrompt = activePreset?.prompt;
const presetName = activePreset?.name;
const agentKey = useMemo(() => {
const slug = slugify(displayName);
return slug || "fox-spirit";
}, [displayName]);
// Deps are plain strings, so this only fires when the translated preset text
// actually changes (locale switch) — not on every render.
useEffect(() => {
if (presetPrompt === undefined) return;
setDescription(presetPrompt);
}, [presetPrompt]);
const selectedEmoji = useMemo(() => {
if (selectedPresetIdx !== null && agentPresets[selectedPresetIdx]) {
return agentPresets[selectedPresetIdx].emoji;
}
return "🦊";
}, [selectedPresetIdx, agentPresets]);
useEffect(() => {
if (presetName === undefined || displayNameEdited || isEditing) return;
setDisplayName(presetName);
}, [presetName, displayNameEdited, isEditing]);
const trimmedName = displayName.trim();
const keyValid = isValidSlug(agentKey);
const canSubmit = !!trimmedName && (isEditing || keyValid) && !!description.trim();
const providerLabel = useMemo(() => {
if (!provider) return "—";
@@ -95,13 +109,6 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
);
useWsEvent("agent.summoning", handleSummoningEvent);
// Sync description when preset changes on initial load
useEffect(() => {
if (selectedPresetIdx !== null && agentPresets[selectedPresetIdx]) {
setDescription(agentPresets[selectedPresetIdx].prompt);
}
}, [selectedPresetIdx, agentPresets]);
const handleContinue = () => {
if (agentResult) onComplete(agentResult);
};
@@ -111,19 +118,35 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
if (!preset) return;
setSelectedPresetIdx(idx);
setDescription(preset.prompt);
setDisplayName(preset.name);
setDisplayNameEdited(false);
setEmoji(preset.emoji);
if (!isEditing) {
setAgentKey(preset.agentKey);
setAgentKeyEdited(false);
}
};
const handleDescriptionChange = (value: string) => {
setDescription(value);
// If user edits text, deselect preset (unless it still matches)
if (selectedPresetIdx !== null) {
const presetPrompt = agentPresets[selectedPresetIdx]?.prompt;
if (value !== presetPrompt) setSelectedPresetIdx(null);
}
// Editing the prompt only clears the preset highlight — identity stays as typed.
if (selectedPresetIdx !== null && value !== presetPrompt) setSelectedPresetIdx(null);
};
const handleDisplayNameChange = (value: string) => {
setDisplayNameEdited(true);
setDisplayName(value);
if (!agentKeyEdited && !isEditing) setAgentKey(slugify(value));
};
const handleAgentKeyChange = (value: string) => {
setAgentKeyEdited(true);
setAgentKey(value);
};
const handleSubmit = async () => {
if (!provider) { setError(t("agent.errors.noProvider")); return; }
if (!canSubmit) return;
setLoading(true);
setError("");
@@ -132,20 +155,20 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
try {
if (isEditing) {
const patch: Partial<AgentData> = {
display_name: displayName.trim() || undefined,
display_name: trimmedName,
provider: provider.name,
model: model || "",
// Promoted fields at top level
agent_description: description.trim() || null,
self_evolve: selfEvolve,
emoji: selectedEmoji || null,
emoji: emoji.trim() || null,
};
await updateAgent(existingAgent!.id, patch);
onComplete({ ...existingAgent!, ...patch } as AgentData);
} else {
const data: Partial<AgentData> = {
agent_key: agentKey,
display_name: displayName.trim() || undefined,
display_name: trimmedName,
provider: provider.name,
model: model || "",
agent_type: "predefined",
@@ -153,7 +176,7 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
// Promoted fields at top level
agent_description: description.trim() || null,
self_evolve: selfEvolve,
emoji: selectedEmoji || null,
emoji: emoji.trim() || null,
grant_gateway_operator_access: gatewayOperatorAccess || undefined,
};
@@ -169,7 +192,7 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
}
setAgentResult(result);
setSummoningOutcome("pending");
setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey });
setCreatedAgent({ id: result.id, name: trimmedName || agentKey });
setSummoningOpen(true);
}
} catch (err) {
@@ -178,7 +201,6 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
setLoading(false);
}
};
const handleSummoningComplete = () => {};
const handleModalClose = async () => {
@@ -221,6 +243,49 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
)}
</div>
{/* Agent identity — editable name + key, prefilled from the preset */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="setup-display-name">{tAgents("create.displayName")}</Label>
<div className="flex gap-2">
<Input
id="setup-emoji"
value={emoji}
onChange={(e) => setEmoji(e.target.value)}
placeholder="🤖"
className="w-14 shrink-0 text-center text-base md:text-lg"
maxLength={2}
title={tAgents("create.emojiHint")}
/>
<Input
id="setup-display-name"
value={displayName}
onChange={(e) => handleDisplayNameChange(e.target.value)}
placeholder={tAgents("create.displayNamePlaceholder")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="setup-agent-key">{tAgents("create.agentKey")}</Label>
<Input
id="setup-agent-key"
value={agentKey}
onChange={(e) => handleAgentKeyChange(e.target.value)}
onBlur={(e) => setAgentKey(slugify(e.target.value))}
placeholder={tAgents("create.agentKeyPlaceholder")}
disabled={isEditing}
/>
<p
className={`text-xs ${
!isEditing && agentKey && !keyValid ? "text-destructive" : "text-muted-foreground"
}`}
>
{tAgents("create.agentKeyHint")}
</p>
</div>
</div>
{/* Prompt / description with preset selection */}
<div className="space-y-3">
<Label className="inline-flex items-center gap-1.5">
@@ -297,7 +362,7 @@ export function StepAgent({ provider, model, onComplete, onBack, existingAgent }
)}
<Button
onClick={handleSubmit}
disabled={loading || !description.trim()}
disabled={loading || !canSubmit}
>
{loading
? isEditing ? t("agent.updating", "Updating...") : t("agent.creating")