From 5ab4af415d2e781749b1777413287665ea2c964c Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 12 Mar 2026 10:19:22 +0700 Subject: [PATCH] feat(setup): add back navigation to onboard wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 2-4 now have a Back button. Steps with DB resources (provider, agent) support edit mode — pre-fill form and use UPDATE instead of INSERT on re-submit. --- ui/web/src/pages/agents/hooks/use-agents.ts | 16 ++++- ui/web/src/pages/setup/setup-page.tsx | 6 ++ ui/web/src/pages/setup/step-agent.tsx | 77 ++++++++++++++------- ui/web/src/pages/setup/step-channel.tsx | 22 +++--- ui/web/src/pages/setup/step-model.tsx | 33 +++++---- ui/web/src/pages/setup/step-provider.tsx | 55 ++++++++++----- 6 files changed, 144 insertions(+), 65 deletions(-) diff --git a/ui/web/src/pages/agents/hooks/use-agents.ts b/ui/web/src/pages/agents/hooks/use-agents.ts index 42c1df74..c5203a53 100644 --- a/ui/web/src/pages/agents/hooks/use-agents.ts +++ b/ui/web/src/pages/agents/hooks/use-agents.ts @@ -75,6 +75,20 @@ export function useAgents() { [http, invalidate], ); + const updateAgent = useCallback( + async (id: string, data: Partial) => { + try { + await http.put(`/v1/agents/${id}`, data); + await invalidate(); + toast.success("Agent updated", `${data.display_name || data.agent_key || "Agent"} has been updated`); + } catch (err) { + toast.error("Failed to update agent", err instanceof Error ? err.message : "Unknown error"); + throw err; + } + }, + [http, invalidate], + ); + const deleteAgent = useCallback( async (id: string) => { try { @@ -96,5 +110,5 @@ export function useAgents() { [http], ); - return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent, resummonAgent }; + return { agents, loading, error, refresh: invalidate, createAgent, updateAgent, deleteAgent, resummonAgent }; } diff --git a/ui/web/src/pages/setup/setup-page.tsx b/ui/web/src/pages/setup/setup-page.tsx index a12d7971..e098a37d 100644 --- a/ui/web/src/pages/setup/setup-page.tsx +++ b/ui/web/src/pages/setup/setup-page.tsx @@ -64,6 +64,7 @@ export function SetupPage() { {step === 1 && ( { setCreatedProvider(provider); setStep(2); @@ -74,6 +75,8 @@ export function SetupPage() { {step === 2 && activeProvider && ( setStep(1)} onComplete={(model) => { setSelectedModel(model); setStep(3); @@ -85,6 +88,8 @@ export function SetupPage() { setStep(2)} onComplete={(agent) => { setCreatedAgent(agent); setStep(4); @@ -95,6 +100,7 @@ export function SetupPage() { {step === 4 && ( setStep(3)} onComplete={handleFinish} onSkip={handleFinish} /> diff --git a/ui/web/src/pages/setup/step-agent.tsx b/ui/web/src/pages/setup/step-agent.tsx index b16bbd1f..d6c892fc 100644 --- a/ui/web/src/pages/setup/step-agent.tsx +++ b/ui/web/src/pages/setup/step-agent.tsx @@ -23,17 +23,25 @@ interface StepAgentProps { provider: ProviderData | null; model: string | null; onComplete: (agent: AgentData) => void; + onBack?: () => void; + existingAgent?: AgentData | null; } -export function StepAgent({ provider, model, onComplete }: StepAgentProps) { +export function StepAgent({ provider, model, onComplete, onBack, existingAgent }: StepAgentProps) { const { t } = useTranslation("setup"); - const { createAgent, deleteAgent, resummonAgent } = useAgents(); + const { createAgent, updateAgent, deleteAgent, resummonAgent } = useAgents(); - const [displayName, setDisplayName] = useState("GoClaw"); - const [agentKey, setAgentKey] = useState("goclaw"); - const [keyTouched, setKeyTouched] = useState(false); - const [description, setDescription] = useState(DEFAULT_PROMPT); - const [selfEvolve, setSelfEvolve] = useState(false); + const isEditing = !!existingAgent; + + const [displayName, setDisplayName] = useState(existingAgent?.display_name ?? "GoClaw"); + const [agentKey, setAgentKey] = useState(existingAgent?.agent_key ?? "goclaw"); + const [keyTouched, setKeyTouched] = useState(isEditing); + const [description, setDescription] = useState( + existingAgent?.other_config?.description as string ?? DEFAULT_PROMPT, + ); + const [selfEvolve, setSelfEvolve] = useState( + !!(existingAgent?.other_config?.self_evolve), + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -72,7 +80,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) { if (agentResult) onComplete(agentResult); }; - const handleCreate = async () => { + const handleSubmit = async () => { if (!agentKey.trim() || !isValidSlug(agentKey)) return; if (!provider) { setError(t("agent.errors.noProvider")); return; } @@ -83,21 +91,34 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) { const otherConfig: Record = {}; if (description.trim()) otherConfig.description = description.trim(); if (selfEvolve) otherConfig.self_evolve = true; - const data: Partial = { - agent_key: agentKey.trim(), - display_name: displayName.trim() || undefined, - provider: provider.name, - model: model || "", - agent_type: "predefined", - is_default: true, - other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined, - }; - const result = await createAgent(data) as AgentData; - setAgentResult(result); - setSummoningOutcome("pending"); - setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey }); - setSummoningOpen(true); + if (isEditing) { + // Update existing agent — skip summoning + const patch: Partial = { + display_name: displayName.trim() || undefined, + provider: provider.name, + model: model || "", + other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined, + }; + await updateAgent(existingAgent!.id, patch); + onComplete({ ...existingAgent!, ...patch } as AgentData); + } else { + const data: Partial = { + agent_key: agentKey.trim(), + display_name: displayName.trim() || undefined, + provider: provider.name, + model: model || "", + agent_type: "predefined", + is_default: true, + other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined, + }; + + const result = await createAgent(data) as AgentData; + setAgentResult(result); + setSummoningOutcome("pending"); + setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey }); + setSummoningOpen(true); + } } catch (err) { setError(err instanceof Error ? err.message : t("agent.errors.failedCreate")); } finally { @@ -172,6 +193,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) { onChange={(e) => { setKeyTouched(true); setAgentKey(e.target.value); }} onBlur={() => setAgentKey(slugify(agentKey))} placeholder={t("agent.agentKeyPlaceholder")} + disabled={isEditing} /> @@ -214,12 +236,17 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) { {error &&

{error}

} -
+
+ {onBack ? ( + + ) : }
diff --git a/ui/web/src/pages/setup/step-channel.tsx b/ui/web/src/pages/setup/step-channel.tsx index 2310c6c9..00ad1dc0 100644 --- a/ui/web/src/pages/setup/step-channel.tsx +++ b/ui/web/src/pages/setup/step-channel.tsx @@ -25,9 +25,10 @@ interface StepChannelProps { agent: AgentData | null; onComplete: () => void; onSkip: () => void; + onBack?: () => void; } -export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) { +export function StepChannel({ agent, onComplete, onSkip, onBack }: StepChannelProps) { const { t } = useTranslation("setup"); const { createInstance } = useChannelInstances(); @@ -158,13 +159,18 @@ export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) { {error &&

{error}

} -
- - +
+ {onBack ? ( + + ) : } +
+ + +
diff --git a/ui/web/src/pages/setup/step-model.tsx b/ui/web/src/pages/setup/step-model.tsx index bbbac44f..494b2752 100644 --- a/ui/web/src/pages/setup/step-model.tsx +++ b/ui/web/src/pages/setup/step-model.tsx @@ -15,14 +15,16 @@ import type { ProviderData } from "@/types/provider"; interface StepModelProps { provider: ProviderData; onComplete: (model: string) => void; + onBack?: () => void; + initialModel?: string | null; } -export function StepModel({ provider, onComplete }: StepModelProps) { +export function StepModel({ provider, onComplete, onBack, initialModel }: StepModelProps) { const { t } = useTranslation("setup"); const { models, loading: modelsLoading } = useProviderModels(provider.id, provider.provider_type); const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify(); - const [model, setModel] = useState(""); + const [model, setModel] = useState(initialModel ?? ""); const [error, setError] = useState(""); // Reset verification when model changes @@ -90,17 +92,22 @@ export function StepModel({ provider, onComplete }: StepModelProps) {
)} -
- - +
+ {onBack ? ( + + ) : } +
+ + +
diff --git a/ui/web/src/pages/setup/step-provider.tsx b/ui/web/src/pages/setup/step-provider.tsx index 8d233c6c..707c5f5b 100644 --- a/ui/web/src/pages/setup/step-provider.tsx +++ b/ui/web/src/pages/setup/step-provider.tsx @@ -17,20 +17,25 @@ import { PROVIDER_TYPES } from "@/constants/providers"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { CLISection } from "@/pages/providers/provider-cli-section"; import { slugify } from "@/lib/slug"; -import type { ProviderData } from "@/types/provider"; +import type { ProviderData, ProviderInput } from "@/types/provider"; interface StepProviderProps { onComplete: (provider: ProviderData) => void; + existingProvider?: ProviderData | null; } -export function StepProvider({ onComplete }: StepProviderProps) { +export function StepProvider({ onComplete, existingProvider }: StepProviderProps) { const { t } = useTranslation("setup"); - const { createProvider } = useProviders(); + const { createProvider, updateProvider } = useProviders(); - const [providerType, setProviderType] = useState("openrouter"); - const [name, setName] = useState("openrouter"); + const isEditing = !!existingProvider; + + const [providerType, setProviderType] = useState(existingProvider?.provider_type ?? "openrouter"); + const [name, setName] = useState(existingProvider?.name ?? "openrouter"); const [apiKey, setApiKey] = useState(""); - const [apiBase, setApiBase] = useState("https://openrouter.ai/api/v1"); + const [apiBase, setApiBase] = useState( + existingProvider?.api_base ?? "https://openrouter.ai/api/v1", + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -54,19 +59,31 @@ export function StepProvider({ onComplete }: StepProviderProps) { [providerType], ); - const handleCreate = async () => { - if (!isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } + const handleSubmit = async () => { + if (!isEditing && !isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } setLoading(true); setError(""); try { - const provider = await createProvider({ - name: name.trim(), - provider_type: providerType, - api_base: apiBase.trim() || undefined, - api_key: isCLI || isOllama ? undefined : apiKey.trim(), - enabled: true, - }) as ProviderData; - onComplete(provider); + if (isEditing) { + const patch: Record = { + name: name.trim(), + provider_type: providerType, + api_base: apiBase.trim() || undefined, + }; + // Only include api_key if user entered a new one + if (apiKey.trim()) patch.api_key = apiKey.trim(); + await updateProvider(existingProvider!.id, patch as Partial); + onComplete({ ...existingProvider!, ...patch } as ProviderData); + } else { + const provider = await createProvider({ + name: name.trim(), + provider_type: providerType, + api_base: apiBase.trim() || undefined, + api_key: isCLI || isOllama ? undefined : apiKey.trim(), + enabled: true, + }) as ProviderData; + onComplete(provider); + } } catch (err) { setError(err instanceof Error ? err.message : t("provider.errors.failedCreate")); } finally { @@ -145,8 +162,10 @@ export function StepProvider({ onComplete }: StepProviderProps) { {error &&

{error}

}
-