diff --git a/src/pages/Cook.tsx b/src/pages/Cook.tsx index 3a3f824..c2c4ec3 100644 --- a/src/pages/Cook.tsx +++ b/src/pages/Cook.tsx @@ -1,11 +1,14 @@ -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useState } from "react"; import { api } from "../lib/api"; import { ParamForm } from "../components/ParamForm"; -import { DiffViewer } from "../components/DiffViewer"; -import { initialState, reducer } from "../lib/state"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { resolveSteps, executeStep, type ResolvedStep } from "../lib/actions"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import type { DiscordGuildChannel } from "../lib/types"; +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, @@ -18,157 +21,188 @@ export function Cook({ recipeSource?: string; discordGuildChannels: DiscordGuildChannel[]; }) { - const [state, dispatch] = useReducer(reducer, initialState); + const [recipe, setRecipe] = useState(null); const [params, setParams] = useState>({}); - const [isApplying, setIsApplying] = useState(false); - const [applied, setApplied] = useState(false); - const [applyError, setApplyError] = useState(""); - const [isPreviewing, setIsPreviewing] = useState(false); + const [phase, setPhase] = useState("params"); + const [resolvedStepList, setResolvedStepList] = useState([]); + const [stepStatuses, setStepStatuses] = useState([]); + const [stepErrors, setStepErrors] = useState>({}); + const [hasConfigPatch, setHasConfigPatch] = useState(false); useEffect(() => { api.listRecipes(recipeSource).then((recipes) => { - const recipe = recipes.find((it) => it.id === recipeId); - dispatch({ type: "setRecipes", recipes }); - if (!recipe) return; - const defaults: Record = {}; - for (const p of recipe.params) { - defaults[p.id] = ""; + const found = recipes.find((it) => it.id === recipeId); + setRecipe(found || null); + if (found) { + const defaults: Record = {}; + for (const p of found.params) { + defaults[p.id] = ""; + } + setParams(defaults); } - setParams(defaults); }); }, [recipeId, recipeSource]); - const recipe = state.recipes.find((r) => r.id === recipeId); - if (!recipe) return
Recipe not found
; - const isCustomAction = !!recipe.action; + const handleNext = () => { + const steps = resolveSteps(recipe.steps, params); + setResolvedStepList(steps); + setStepStatuses(steps.map(() => "pending")); + setStepErrors({}); + setHasConfigPatch(steps.some((s) => s.action === "config_patch")); + setPhase("confirm"); + }; - const handleApply = async () => { - setIsApplying(true); - setApplyError(""); - try { - const result = await api.applyRecipe(recipe.id, params, recipeSource); - if (!result.ok) { - const errors = result.errors.length ? result.errors.join(", ") : "failed"; - setApplyError(`Apply failed: ${errors}`); + 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; } - setApplied(true); - } catch (err) { - setApplyError(String(err)); - } finally { - setIsApplying(false); + setStepStatuses([...statuses]); + } + setPhase("done"); + }; + + const handleExecute = () => { + setPhase("execute"); + const statuses: StepStatus[] = resolvedStepList.map(() => "pending"); + setStepStatuses([...statuses]); + 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 handleCustomAction = async () => { - setIsApplying(true); - setApplyError(""); - try { - if (recipe.action === "setup_agent") { - await api.setupAgentIdentity( - params.agent_id, - params.name, - params.emoji || undefined, - ); - } else { - throw new Error(`Unknown action: ${recipe.action}`); - } - setApplied(true); - } catch (err) { - setApplyError(String(err)); - } finally { - setIsApplying(false); + 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 successMessage = isCustomAction - ? "Done" - : "Config updated"; + 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 successHint = isCustomAction - ? "Agent identity has been updated." - : "Use \"Apply Changes\" in the sidebar to restart the gateway and activate the changes."; + const doneCount = stepStatuses.filter((s) => s === "done").length; + const skippedCount = stepStatuses.filter((s) => s === "skipped").length; return (
-

- Cook {recipe.name} -

+

{recipe.name}

- {applied ? ( + {phase === "params" && ( + setParams((prev) => ({ ...prev, [id]: value }))} + onSubmit={handleNext} + submitLabel="Next" + discordGuildChannels={discordGuildChannels} + /> + )} + + {(phase === "confirm" || phase === "execute") && ( + + +
+ {resolvedStepList.map((step, i) => ( +
+ + {statusIcon(stepStatuses[i])} + +
+
{step.label}
+ {step.description !== step.label && ( +
{step.description}
+ )} + {stepErrors[i] && ( +
{stepErrors[i]}
+ )} + {stepStatuses[i] === "failed" && ( +
+ + +
+ )} +
+
+ ))} +
+ {phase === "confirm" && ( +
+ + +
+ )} +
+
+ )} + + {phase === "done" && (
-

{successMessage}

-

- {successHint} +

+ {doneCount} step{doneCount !== 1 ? "s" : ""} completed + {skippedCount > 0 && `, ${skippedCount} skipped`}

+ {hasConfigPatch && ( +

+ Use "Apply Changes" in the sidebar to restart the gateway and activate config changes. +

+ )}
- ) : ( - <> - setParams((prev) => ({ ...prev, [id]: value }))} - onSubmit={() => { - if (isCustomAction) { - handleCustomAction(); - } else { - setIsPreviewing(true); - api.previewApply(recipe.id, params, recipeSource) - .then((preview) => dispatch({ type: "setPreview", preview })) - .catch((err) => dispatch({ type: "setMessage", message: String(err) })) - .finally(() => setIsPreviewing(false)); - } - }} - submitLabel={isCustomAction ? "Apply" : "Preview"} - discordGuildChannels={discordGuildChannels} - /> - {isCustomAction && applyError && ( -

{applyError}

- )} - {isCustomAction && isApplying && ( -

Applying...

- )} - {state.lastPreview && ( - - - - Preview - - - - -
- - {isApplying && ( - Applying config... - )} - {applyError && ( - {applyError} - )} - {isPreviewing && ( - Previewing... - )} -
-
-
- )} - {!isCustomAction && ( -

{state.message}

- )} - )}
);