refactor: streamline recipe execution flows with validation support and enhanced run dialog interactions

This commit is contained in:
Shine1i 2026-02-23 20:28:41 +01:00
commit d4655eb8bf
5 changed files with 198 additions and 24 deletions

View file

@ -2,7 +2,6 @@ import { type KeyboardEvent, type ReactElement, useState } from "react";
import {
CookBookIcon,
FloppyDiskIcon,
TestTubeIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
@ -15,16 +14,12 @@ type StatusTone = "success" | "error";
type RecipeStudioHeaderProps = {
activeView: RecipeStudioView;
previewLoading: boolean;
fullLoading: boolean;
saveLoading: boolean;
saveTone: StatusTone;
savedAtLabel: string;
workflowName: string;
onWorkflowNameChange: (value: string) => void;
onViewChange: (view: RecipeStudioView) => void;
onPreview: () => void;
onRunFull: () => void;
onSaveRecipe: () => void;
};
@ -35,16 +30,12 @@ const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
export function RecipeStudioHeader({
activeView,
previewLoading,
fullLoading,
saveLoading,
saveTone,
savedAtLabel,
workflowName,
onWorkflowNameChange,
onViewChange,
onPreview,
onRunFull,
onSaveRecipe,
}: RecipeStudioHeaderProps): ReactElement {
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
@ -116,14 +107,6 @@ export function RecipeStudioHeader({
</Tabs>
</div>
<div className="flex items-center justify-self-end gap-2">
<Button type="button" size="sm" onClick={onPreview} disabled={previewLoading}>
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
{previewLoading ? "Previewing..." : "Preview"}
</Button>
<Button type="button" size="sm" onClick={onRunFull} disabled={fullLoading}>
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
{fullLoading ? "Starting..." : "Full run"}
</Button>
<Button
type="button"
size="sm"

View file

@ -1,4 +1,9 @@
import { type ReactElement, useMemo, useState } from "react";
import {
CookBookIcon,
TestTube01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -22,13 +27,21 @@ type RunDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: RecipeExecutionKind;
onKindChange: (kind: RecipeExecutionKind) => void;
rows: number;
onRowsChange: (rows: number) => void;
settings: RecipeRunSettings;
onSettingsChange: (patch: Partial<RecipeRunSettings>) => void;
loading: boolean;
validateLoading: boolean;
validateResult: {
valid: boolean;
errors: string[];
rawDetail: string | null;
} | null;
errors: string[];
onRun: () => void;
onValidate: () => void;
container?: HTMLDivElement | null;
};
@ -63,13 +76,17 @@ export function RunDialog({
open,
onOpenChange,
kind,
onKindChange,
rows,
onRowsChange,
settings,
onSettingsChange,
loading,
validateLoading,
validateResult,
errors,
onRun,
onValidate,
container,
}: RunDialogProps): ReactElement {
const [advancedOpen, setAdvancedOpen] = useState(false);
@ -98,6 +115,16 @@ export function RunDialog({
</p>
</DialogHeader>
<label className="flex items-center justify-between rounded-xl border bg-muted/20 px-3 py-2 text-sm">
<span className="font-medium text-foreground">Preview mode</span>
<Switch
checked={kind === "preview"}
onCheckedChange={(checked) =>
onKindChange(checked ? "preview" : "full")
}
/>
</label>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<FieldLabel
@ -319,7 +346,7 @@ export function RunDialog({
{errors.length > 0 && (
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-semibold uppercase text-destructive">
Validation errors
Run checks
</p>
{errors.map((error) => (
<p key={error} className="text-xs text-destructive">
@ -329,6 +356,38 @@ export function RunDialog({
</div>
)}
{validateResult && (
<div
className={
validateResult.valid
? "space-y-1 rounded-xl border border-emerald-300 bg-emerald-50 p-3"
: "space-y-1 rounded-xl border border-destructive/30 bg-destructive/5 p-3"
}
>
<p
className={
validateResult.valid
? "text-xs font-semibold uppercase text-emerald-700"
: "text-xs font-semibold uppercase text-destructive"
}
>
{validateResult.valid ? "Validation passed" : "Validation failed"}
</p>
{!validateResult.valid && validateResult.errors.length > 0 && (
<div className="space-y-1">
{validateResult.errors.map((error) => (
<p key={error} className="text-xs text-destructive">
{error}
</p>
))}
</div>
)}
{!validateResult.valid && validateResult.rawDetail && (
<p className="text-xs text-destructive">{validateResult.rawDetail}</p>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
@ -338,7 +397,17 @@ export function RunDialog({
>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={loading || validateLoading}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-3.5" />
{validateLoading ? "Validating..." : "Validate recipe"}
</Button>
<Button type="button" onClick={onRun} disabled={loading}>
<HugeiconsIcon icon={CookBookIcon} className="size-3.5" />
{loading ? "Starting..." : `Start ${kindLabel.toLowerCase()}`}
</Button>
</DialogFooter>

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { toastError } from "@/shared/toast";
import {
@ -46,6 +46,7 @@ type UseRecipeExecutionsParams = {
type UseRecipeExecutionsResult = {
runDialogOpen: boolean;
runDialogKind: RecipeExecutionKind;
setRunDialogKind: (kind: RecipeExecutionKind) => void;
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
@ -61,6 +62,13 @@ type UseRecipeExecutionsResult = {
setSelectedExecutionId: (id: string) => void;
openRunDialog: (kind: RecipeExecutionKind) => void;
runFromDialog: () => Promise<boolean>;
validateFromDialog: () => Promise<boolean>;
validateLoading: boolean;
validateResult: {
valid: boolean;
errors: string[];
rawDetail: string | null;
} | null;
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
@ -74,6 +82,12 @@ export function useRecipeExecutions({
onExecutionStart,
onPreviewSuccess,
}: UseRecipeExecutionsParams): UseRecipeExecutionsResult {
const [validateLoading, setValidateLoading] = useState(false);
const [validateResult, setValidateResult] = useState<{
valid: boolean;
errors: string[];
rawDetail: string | null;
} | null>(null);
const {
runDialogOpen,
runDialogKind,
@ -335,9 +349,64 @@ export function useRecipeExecutions({
return runFull();
}, [runDialogKind, runFull, runPreview]);
const validateFromDialog = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
if (!payload) {
const nextErrors = payloadResult.errors.length > 0
? payloadResult.errors
: [payloadErrorMessage];
setValidateResult({
valid: false,
errors: nextErrors,
rawDetail: null,
});
return false;
}
const rows = runDialogKind === "preview" ? previewRows : fullRows;
const normalizedRows = sanitizeExecutionRows(rows, runDialogKind);
const executionPayload = buildExecutionPayload({
payload,
kind: runDialogKind,
rows: normalizedRows,
settings: runSettings,
});
setValidateLoading(true);
try {
const validation = await validateRecipe(executionPayload);
const errors = validation.errors.map((item) => item.message);
setValidateResult({
valid: validation.valid,
errors,
rawDetail: validation.raw_detail ?? null,
});
return validation.valid;
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setValidateResult({
valid: false,
errors: [message],
rawDetail: null,
});
return false;
} finally {
setValidateLoading(false);
}
}, [
fullRows,
payloadErrorMessage,
payloadResult.errors,
previewRows,
readPayload,
runDialogKind,
runSettings,
]);
const openRunDialog = useCallback(
(kind: RecipeExecutionKind): void => {
setRunErrors([]);
setValidateResult(null);
setRunDialogKind(kind);
if (kind === "full") {
const payload = readPayload();
@ -418,6 +487,7 @@ export function useRecipeExecutions({
return {
runDialogOpen,
runDialogKind,
setRunDialogKind,
setRunDialogOpen,
previewRows,
fullRows,
@ -433,6 +503,9 @@ export function useRecipeExecutions({
setSelectedExecutionId,
openRunDialog,
runFromDialog,
validateFromDialog,
validateLoading,
validateResult,
runPreview,
runFull,
cancelExecution,

View file

@ -44,6 +44,7 @@ type UseRecipeStudioActionsResult = {
setImportOpen: (open: boolean) => void;
runDialogOpen: boolean;
runDialogKind: RecipeExecutionKind;
setRunDialogKind: (kind: RecipeExecutionKind) => void;
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
@ -61,6 +62,13 @@ type UseRecipeStudioActionsResult = {
persistRecipe: () => Promise<void>;
openRunDialog: (kind: RecipeExecutionKind) => void;
runFromDialog: () => Promise<boolean>;
validateFromDialog: () => Promise<boolean>;
validateLoading: boolean;
validateResult: {
valid: boolean;
errors: string[];
rawDetail: string | null;
} | null;
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
@ -113,6 +121,7 @@ export function useRecipeStudioActions({
setImportOpen: persistence.setImportOpen,
runDialogOpen: executions.runDialogOpen,
runDialogKind: executions.runDialogKind,
setRunDialogKind: executions.setRunDialogKind,
setRunDialogOpen: executions.setRunDialogOpen,
previewRows: executions.previewRows,
fullRows: executions.fullRows,
@ -130,6 +139,9 @@ export function useRecipeStudioActions({
persistRecipe: persistence.persistRecipe,
openRunDialog: executions.openRunDialog,
runFromDialog: executions.runFromDialog,
validateFromDialog: executions.validateFromDialog,
validateLoading: executions.validateLoading,
validateResult: executions.validateResult,
runPreview: executions.runPreview,
runFull: executions.runFull,
cancelExecution: executions.cancelExecution,

View file

@ -10,7 +10,11 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import {
CookBookIcon,
PlusSignIcon,
TestTube01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ReactElement,
@ -31,6 +35,7 @@ import { RecipeStudioHeader } from "./components/recipe-studio-header";
import { RecipeNode } from "./components/recipe-graph-node";
import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge";
import { DataEdge } from "./components/rf-ui/data-edge";
import { Button } from "@/components/ui/button";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { RunDialog } from "./dialogs/preview-dialog";
@ -283,6 +288,7 @@ export function RecipeStudioPage({
setImportOpen,
runDialogOpen,
runDialogKind,
setRunDialogKind,
setRunDialogOpen,
previewRows,
fullRows,
@ -300,6 +306,9 @@ export function RecipeStudioPage({
persistRecipe,
openRunDialog,
runFromDialog,
validateFromDialog,
validateLoading,
validateResult,
cancelExecution,
loadExecutionDatasetPage,
copyRecipe,
@ -346,16 +355,12 @@ export function RecipeStudioPage({
>
<RecipeStudioHeader
activeView={activeView}
previewLoading={previewLoading}
fullLoading={fullLoading}
saveLoading={saveLoading}
saveTone={saveTone}
savedAtLabel={savedAtLabel}
workflowName={workflowName}
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onPreview={() => openRunDialog("preview")}
onRunFull={() => openRunDialog("full")}
onSaveRecipe={() => {
void persistRecipe();
}}
@ -441,6 +446,32 @@ export function RecipeStudioPage({
interactive={interactive}
onToggleInteractive={toggleInteractive}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2">
<Button
type="button"
className="h-11 px-5"
onClick={() => openRunDialog(runDialogKind)}
disabled={previewLoading || fullLoading}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
{previewLoading || fullLoading ? "Running..." : "Run"}
</Button>
<Button
type="button"
variant="outline"
className="h-11 px-5"
onClick={() => {
openRunDialog(runDialogKind);
void validateFromDialog();
}}
disabled={validateLoading}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
{validateLoading ? "Validating..." : "Validate"}
</Button>
</div>
</div>
</ReactFlow>
) : (
<ExecutionsView
@ -487,6 +518,7 @@ export function RecipeStudioPage({
open={runDialogOpen}
onOpenChange={setRunDialogOpen}
kind={runDialogKind}
onKindChange={setRunDialogKind}
rows={runDialogRows}
onRowsChange={(rows) => {
if (runDialogKind === "preview") {
@ -498,7 +530,12 @@ export function RecipeStudioPage({
settings={runSettings}
onSettingsChange={setRunSettings}
loading={runDialogLoading}
validateLoading={validateLoading}
validateResult={validateResult}
errors={runErrors}
onValidate={() => {
void validateFromDialog();
}}
onRun={() => {
void runFromDialog();
}}