Files
clawpal/src/pages/Cook.tsx
zhixian 065b726e65 fix: show restart hint after any config-modifying steps, not just config_patch
create_agent, bind_channel, setup_identity all modify openclaw.json
and need a gateway restart. Previously the hint only showed when
config_patch steps ran.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:45:02 +09:00

217 lines
7.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { ParamForm } from "../components/ParamForm";
import { resolveSteps, executeStep, type ResolvedStep } from "../lib/actions";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { DiscordGuildChannel, Recipe } from "../lib/types";
type Phase = "params" | "confirm" | "execute" | "done";
type StepStatus = "pending" | "running" | "done" | "failed" | "skipped";
export function Cook({
recipeId,
onDone,
recipeSource,
discordGuildChannels,
}: {
recipeId: string;
onDone?: () => void;
recipeSource?: string;
discordGuildChannels: DiscordGuildChannel[];
}) {
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [params, setParams] = useState<Record<string, string>>({});
const [phase, setPhase] = useState<Phase>("params");
const [resolvedStepList, setResolvedStepList] = useState<ResolvedStep[]>([]);
const [stepStatuses, setStepStatuses] = useState<StepStatus[]>([]);
const [stepErrors, setStepErrors] = useState<Record<number, string>>({});
const [needsRestart, setNeedsRestart] = useState(false);
useEffect(() => {
api.listRecipes(recipeSource).then((recipes) => {
const found = recipes.find((it) => it.id === recipeId);
setRecipe(found || null);
if (found) {
const defaults: Record<string, string> = {};
for (const p of found.params) {
defaults[p.id] = p.defaultValue ?? (p.type === "boolean" ? "false" : "");
}
setParams(defaults);
}
});
}, [recipeId, recipeSource]);
if (!recipe) return <div>Recipe not found</div>;
const handleNext = () => {
const steps = resolveSteps(recipe.steps, params);
setResolvedStepList(steps);
// Auto-skip steps whose template args resolved to empty
setStepStatuses(steps.map((s) => (s.skippable ? "skipped" : "pending")));
setStepErrors({});
setNeedsRestart(steps.some((s) => !s.skippable));
setPhase("confirm");
};
const runFrom = async (startIndex: number, statuses: StepStatus[]) => {
for (let i = startIndex; i < resolvedStepList.length; i++) {
if (statuses[i] === "skipped") continue;
statuses[i] = "running";
setStepStatuses([...statuses]);
try {
await executeStep(resolvedStepList[i]);
statuses[i] = "done";
} catch (err) {
statuses[i] = "failed";
setStepErrors((prev) => ({ ...prev, [i]: String(err) }));
setStepStatuses([...statuses]);
return;
}
setStepStatuses([...statuses]);
}
setPhase("done");
};
const handleExecute = () => {
setPhase("execute");
const statuses = [...stepStatuses];
runFrom(0, statuses);
};
const handleRetry = (index: number) => {
const statuses = [...stepStatuses];
setStepErrors((prev) => {
const next = { ...prev };
delete next[index];
return next;
});
runFrom(index, statuses);
};
const handleSkip = (index: number) => {
const statuses = [...stepStatuses];
statuses[index] = "skipped";
setStepStatuses(statuses);
setStepErrors((prev) => {
const next = { ...prev };
delete next[index];
return next;
});
const nextIndex = statuses.findIndex((s, i) => i > index && s !== "skipped");
if (nextIndex === -1) {
setPhase("done");
} else {
runFrom(nextIndex, statuses);
}
};
const statusIcon = (s: StepStatus) => {
switch (s) {
case "pending": return "\u25CB";
case "running": return "\u25C9";
case "done": return "\u2713";
case "failed": return "\u2717";
case "skipped": return "\u2013";
}
};
const statusColor = (s: StepStatus) => {
switch (s) {
case "done": return "text-green-600";
case "failed": return "text-destructive";
case "running": return "text-primary";
default: return "text-muted-foreground";
}
};
const doneCount = stepStatuses.filter((s) => s === "done").length;
const skippedCount = stepStatuses.filter((s) => s === "skipped").length;
return (
<section>
<div className="flex items-center gap-2 mb-4">
<Button variant="ghost" size="sm" className="px-2" onClick={onDone}>
&larr;
</Button>
<h2 className="text-2xl font-bold">{recipe.name}</h2>
</div>
{phase === "params" && (
<ParamForm
recipe={recipe}
values={params}
onChange={(id, value) => setParams((prev) => ({ ...prev, [id]: value }))}
onSubmit={handleNext}
submitLabel="Next"
discordGuildChannels={discordGuildChannels}
/>
)}
{(phase === "confirm" || phase === "execute") && (
<Card>
<CardContent>
<div className="space-y-3">
{resolvedStepList.map((step, i) => (
<div key={i} className={cn("flex items-start gap-3", stepStatuses[i] === "skipped" && "opacity-50")}>
<span className={cn("text-lg font-mono w-5 text-center", statusColor(stepStatuses[i]))}>
{statusIcon(stepStatuses[i])}
</span>
<div className="flex-1">
<div className="text-sm font-medium">
{step.label}
{stepStatuses[i] === "skipped" && phase === "confirm" && (
<span className="text-xs text-muted-foreground ml-2">(skipped empty params)</span>
)}
</div>
{step.description !== step.label && stepStatuses[i] !== "skipped" && (
<div className="text-xs text-muted-foreground">{step.description}</div>
)}
{stepErrors[i] && (
<div className="text-xs text-destructive mt-1">{stepErrors[i]}</div>
)}
{stepStatuses[i] === "failed" && (
<div className="flex gap-2 mt-1.5">
<Button size="sm" variant="outline" onClick={() => handleRetry(i)}>
Retry
</Button>
<Button size="sm" variant="ghost" onClick={() => handleSkip(i)}>
Skip
</Button>
</div>
)}
</div>
</div>
))}
</div>
{phase === "confirm" && (
<div className="flex gap-2 mt-4">
<Button onClick={handleExecute}>Execute</Button>
<Button variant="outline" onClick={() => setPhase("params")}>Back</Button>
</div>
)}
</CardContent>
</Card>
)}
{phase === "done" && (
<Card>
<CardContent className="py-8 text-center">
<div className="text-2xl mb-2">&#10003;</div>
<p className="text-lg font-medium">
{doneCount} step{doneCount !== 1 ? "s" : ""} completed
{skippedCount > 0 && `, ${skippedCount} skipped`}
</p>
{needsRestart && (
<p className="text-sm text-muted-foreground mt-1">
Use "Apply Changes" in the sidebar to restart the gateway and activate config changes.
</p>
)}
</CardContent>
</Card>
)}
</section>
);
}