feat(setup): add back navigation to onboard wizard

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.
This commit is contained in:
viettranx committed 2026-03-12 10:19:22 +07:00
1 parent dcf9ec4d49
commit 5ab4af415d
6 files changed
+144 -65

No files matched your search

+15 -1
View File
@@ -75,6 +75,20 @@ export function useAgents() {
[http, invalidate],
);
const updateAgent = useCallback(
async (id: string, data: Partial<AgentData>) => {
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 };
}
+6
View File
@@ -64,6 +64,7 @@ export function SetupPage() {
{step === 1 && (
<StepProvider
existingProvider={createdProvider}
onComplete={(provider) => {
setCreatedProvider(provider);
setStep(2);
@@ -74,6 +75,8 @@ export function SetupPage() {
{step === 2 && activeProvider && (
<StepModel
provider={activeProvider}
initialModel={selectedModel}
onBack={() => setStep(1)}
onComplete={(model) => {
setSelectedModel(model);
setStep(3);
@@ -85,6 +88,8 @@ export function SetupPage() {
<StepAgent
provider={activeProvider}
model={selectedModel}
existingAgent={createdAgent}
onBack={() => setStep(2)}
onComplete={(agent) => {
setCreatedAgent(agent);
setStep(4);
@@ -95,6 +100,7 @@ export function SetupPage() {
{step === 4 && (
<StepChannel
agent={activeAgent}
onBack={() => setStep(3)}
onComplete={handleFinish}
onSkip={handleFinish}
/>
+52 -25
View File
@@ -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<string, unknown> = {};
if (description.trim()) otherConfig.description = description.trim();
if (selfEvolve) otherConfig.self_evolve = true;
const data: Partial<AgentData> = {
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<AgentData> = {
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<AgentData> = {
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}
/>
</div>
</div>
@@ -214,12 +236,17 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
) : <span />}
<Button
onClick={handleCreate}
onClick={handleSubmit}
disabled={loading || !agentKey.trim() || !isValidSlug(agentKey) || !description.trim()}
>
{loading ? t("agent.creating") : t("agent.create")}
{loading
? isEditing ? t("agent.updating", "Updating...") : t("agent.creating")
: isEditing ? t("agent.update", "Update") : t("agent.create")}
</Button>
</div>
</TooltipProvider>
+14 -8
View File
@@ -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 && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("channel.skipFinish")}
</Button>
<Button onClick={handleCreate} disabled={loading}>
{loading ? t("channel.creating") : t("channel.create")}
</Button>
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
) : <span />}
<div className="flex gap-2">
<Button variant="outline" onClick={onSkip} disabled={loading}>
{t("channel.skipFinish")}
</Button>
<Button onClick={handleCreate} disabled={loading}>
{loading ? t("channel.creating") : t("channel.create")}
</Button>
</div>
</div>
</TooltipProvider>
</CardContent>
+20 -13
View File
@@ -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) {
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleVerify}
disabled={!model.trim() || verifying || isVerified}
>
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
</Button>
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
{t("model.continue")}
</Button>
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
) : <span />}
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleVerify}
disabled={!model.trim() || verifying || isVerified}
>
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
</Button>
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
{t("model.continue")}
</Button>
</div>
</div>
</TooltipProvider>
</CardContent>
+37 -18
View File
@@ -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<string, unknown> = {
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<ProviderInput>);
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 && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<Button onClick={handleCreate} disabled={loading || (!isCLI && !isOllama && !apiKey.trim())}>
{loading ? t("provider.creating") : t("provider.create")}
<Button onClick={handleSubmit} disabled={loading || (!isEditing && !isCLI && !isOllama && !apiKey.trim())}>
{loading
? isEditing ? t("provider.updating", "Updating...") : t("provider.creating")
: isEditing ? t("provider.update", "Update") : t("provider.create")}
</Button>
</div>
</TooltipProvider>