feat: introduce execution tracking and analysis for recipe preview

- Added `ExecutionsView` with execution history tracking, live updates, and detailed data analysis.
- Implemented IndexedDB support via Dexie to persist execution records locally.
- Enhanced backend preview logic to return execution analysis and artifacts.
- Updated studio header with view toggling between "Editor" and "Executions."
This commit is contained in:
Shine1i 2026-02-20 11:34:25 +01:00
commit d378e48c2a
10 changed files with 503 additions and 68 deletions

View file

@ -128,7 +128,7 @@ def validate_recipe(recipe: dict[str, Any]) -> None:
def preview_recipe(
recipe: dict[str, Any],
num_records: int,
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
results = designer.preview(builder, num_records=num_records)
@ -143,5 +143,10 @@ def preview_recipe(
if results.processor_artifacts is None
else _to_jsonable(results.processor_artifacts)
)
analysis = (
None
if results.analysis is None
else _to_jsonable(results.analysis.model_dump(mode="json"))
)
return dataset, artifacts
return dataset, artifacts, analysis

View file

@ -18,6 +18,7 @@ class RecipePayload(BaseModel):
class PreviewResponse(BaseModel):
dataset: list[dict[str, Any]] = Field(default_factory=list)
processor_artifacts: dict[str, Any] | None = None
analysis: dict[str, Any] | None = None
class ValidateError(BaseModel):
@ -34,4 +35,3 @@ class ValidateResponse(BaseModel):
class JobCreateResponse(BaseModel):
job_id: str

View file

@ -57,13 +57,13 @@ def preview(payload: RecipePayload) -> PreviewResponse:
num_records = int(run.get("rows") or 5)
try:
dataset, artifacts = preview_recipe(recipe, num_records)
dataset, artifacts, analysis = preview_recipe(recipe, num_records)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return PreviewResponse(dataset=dataset, processor_artifacts=artifacts)
return PreviewResponse(dataset=dataset, processor_artifacts=artifacts, analysis=analysis)
@router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse)

View file

@ -1,4 +1,4 @@
const DEFAULT_BASE = "";
const DEFAULT_BASE = "/api/data-recipe";
export const DATA_DESIGNER_API_BASE =
import.meta.env.VITE_DATA_DESIGNER_API ?? DEFAULT_BASE;
@ -7,6 +7,7 @@ export type PreviewResponse = {
dataset?: unknown[];
// biome-ignore lint/style/useNamingConvention: api schema
processor_artifacts?: Record<string, unknown>;
analysis?: Record<string, unknown>;
};
export type ValidateError = {

View file

@ -0,0 +1,223 @@
import { useMemo, type ReactElement } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/ui/data-table";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { RecipeExecutionRecord } from "../../execution-types";
type ExecutionsViewProps = {
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
currentSignature: string;
previewLoading: boolean;
onSelectExecution: (id: string) => void;
onRunPreview: () => void;
};
function formatTimestamp(value: number): string {
return new Date(value).toLocaleString();
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) {
return "--";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function statusTone(status: RecipeExecutionRecord["status"]): string {
if (status === "completed") {
return "bg-emerald-100 text-emerald-700";
}
if (status === "error") {
return "bg-red-100 text-red-700";
}
return "bg-amber-100 text-amber-700";
}
export function ExecutionsView({
executions,
selectedExecutionId,
currentSignature,
previewLoading,
onSelectExecution,
onRunPreview,
}: ExecutionsViewProps): ReactElement {
const selectedExecution = useMemo(
() =>
executions.find((execution) => execution.id === selectedExecutionId) ??
null,
[executions, selectedExecutionId],
);
const isStale = Boolean(
selectedExecution &&
selectedExecution.recipeSignature.length > 0 &&
selectedExecution.recipeSignature !== currentSignature,
);
const tableColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
if (!selectedExecution) {
return [];
}
const names = new Set<string>();
for (const row of selectedExecution.dataset) {
for (const key of Object.keys(row)) {
names.add(key);
}
}
return Array.from(names).map((name) => ({
accessorKey: name,
header: name,
cell: ({ getValue }) => {
const value = getValue();
return (
<p className="max-w-[32rem] whitespace-pre-wrap break-all">
{formatCellValue(value)}
</p>
);
},
}));
}, [selectedExecution]);
return (
<div className="flex h-full min-h-0">
<aside className="w-72 shrink-0 border-r">
<div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Executions
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={onRunPreview}
disabled={previewLoading}
>
{previewLoading ? "Running..." : "Run preview"}
</Button>
</div>
<div className="h-[calc(100%-45px)] overflow-auto p-2">
{executions.length === 0 ? (
<div className="rounded-xl border border-dashed p-3 text-xs text-muted-foreground">
No executions yet.
</div>
) : (
executions.map((execution) => (
<button
key={execution.id}
type="button"
onClick={() => onSelectExecution(execution.id)}
className={cn(
"mb-2 w-full rounded-xl corner-squircle border p-3 text-left",
selectedExecutionId === execution.id
? "border-primary/50 bg-primary/5"
: "hover:bg-muted/40",
)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium capitalize">
{execution.kind}
</p>
<Badge
variant="secondary"
className={cn("capitalize", statusTone(execution.status))}
>
{execution.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{execution.rows} rows
</p>
<p className="text-xs text-muted-foreground">
{formatTimestamp(execution.createdAt)}
</p>
</button>
))
)}
</div>
</aside>
<section className="min-w-0 flex-1 overflow-auto p-4">
{!selectedExecution ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
Select an execution.
</div>
) : (
<div className="space-y-4">
<div className="rounded-xl border p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold capitalize">
{selectedExecution.kind} execution
</p>
<Badge
variant="secondary"
className={cn("capitalize", statusTone(selectedExecution.status))}
>
{selectedExecution.status}
</Badge>
{isStale && (
<Badge variant="outline">Recipe changed since this run</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
Started {formatTimestamp(selectedExecution.createdAt)} |{" "}
{selectedExecution.rows} rows
</p>
</div>
{selectedExecution.status === "running" && (
<div className="space-y-2 rounded-xl border p-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-20 w-full" />
</div>
)}
{selectedExecution.status === "error" && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-destructive">Preview failed</p>
<p className="text-xs text-destructive">
{selectedExecution.error ?? "Unknown error."}
</p>
</div>
)}
{selectedExecution.status === "completed" && (
<>
<div className="rounded-xl border p-3">
<p className="mb-2 text-sm font-semibold">Analysis (full)</p>
<pre className="max-h-80 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(selectedExecution.analysis ?? {}, null, 2)}
</pre>
</div>
<div className="rounded-xl border p-3">
<p className="mb-2 text-sm font-semibold">Preview data</p>
{selectedExecution.dataset.length === 0 ? (
<p className="text-xs text-muted-foreground">No rows returned.</p>
) : (
<div className="max-h-[55vh] overflow-auto">
<DataTable
columns={tableColumns}
data={selectedExecution.dataset}
/>
</div>
)}
</div>
</>
)}
</div>
)}
</section>
</div>
);
}

View file

@ -8,16 +8,20 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { RecipeStudioView } from "../execution-types";
type StatusTone = "success" | "error";
type RecipeStudioHeaderProps = {
activeView: RecipeStudioView;
previewLoading: boolean;
saveLoading: boolean;
saveTone: StatusTone;
savedAtLabel: string;
workflowName: string;
onWorkflowNameChange: (value: string) => void;
onViewChange: (view: RecipeStudioView) => void;
onPreview: () => void;
onSaveRecipe: () => void;
};
@ -28,17 +32,25 @@ const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
};
export function RecipeStudioHeader({
activeView,
previewLoading,
saveLoading,
saveTone,
savedAtLabel,
workflowName,
onWorkflowNameChange,
onViewChange,
onPreview,
onSaveRecipe,
}: RecipeStudioHeaderProps): ReactElement {
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
function handleViewValueChange(value: string): void {
if (value === "editor" || value === "executions") {
onViewChange(value);
}
}
function closeWorkflowNameEditor(): void {
if (workflowName.trim().length === 0) {
onWorkflowNameChange("Unnamed");
@ -57,7 +69,7 @@ export function RecipeStudioHeader({
}
return (
<div className="flex items-center justify-between gap-4 border-b px-4 py-3">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
@ -91,7 +103,15 @@ export function RecipeStudioHeader({
<span className="text-xs text-muted-foreground">{savedAtLabel}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="justify-self-center">
<Tabs value={activeView} onValueChange={handleViewValueChange}>
<TabsList>
<TabsTrigger value="editor">Editor</TabsTrigger>
<TabsTrigger value="executions">Executions</TabsTrigger>
</TabsList>
</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"}

View file

@ -0,0 +1,23 @@
import Dexie, { type EntityTable } from "dexie";
import type { RecipeExecutionRecord } from "../execution-types";
const db = new Dexie("unsloth-data-recipe-executions") as Dexie & {
executions: EntityTable<RecipeExecutionRecord, "id">;
};
db.version(1).stores({
executions: "id, recipeId, kind, status, createdAt",
});
export async function listRecipeExecutions(
recipeId: string,
): Promise<RecipeExecutionRecord[]> {
const executions = await db.executions.where("recipeId").equals(recipeId).toArray();
return executions.sort((a, b) => b.createdAt - a.createdAt);
}
export async function saveRecipeExecution(
execution: RecipeExecutionRecord,
): Promise<void> {
await db.executions.put(execution);
}

View file

@ -0,0 +1,20 @@
export type RecipeStudioView = "editor" | "executions";
export type RecipeExecutionKind = "preview" | "full";
export type RecipeExecutionStatus = "running" | "completed" | "error";
export type RecipeExecutionRecord = {
id: string;
recipeId: string;
kind: RecipeExecutionKind;
status: RecipeExecutionStatus;
rows: number;
createdAt: number;
recipeSignature: string;
dataset: Record<string, unknown>[];
analysis: Record<string, unknown> | null;
// biome-ignore lint/style/useNamingConvention: api schema
processor_artifacts: Record<string, unknown> | null;
error: string | null;
};

View file

@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toastError, toastSuccess } from "@/shared/toast";
import { normalizeNonEmptyName } from "@/utils";
import { previewRecipe, validateRecipe } from "../api";
import { listRecipeExecutions, saveRecipeExecution } from "../data/executions-db";
import type { RecipeExecutionRecord } from "../execution-types";
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
@ -26,6 +28,7 @@ type UseRecipeStudioActionsParams = {
resetRecipe: () => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
getCurrentPayloadFromStore: () => RecipePayload;
onPreviewSuccess?: () => void;
};
type UseRecipeStudioActionsResult = {
@ -43,9 +46,13 @@ type UseRecipeStudioActionsResult = {
setPreviewRows: (rows: number) => void;
previewErrors: string[];
previewLoading: boolean;
currentSignature: string;
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
setSelectedExecutionId: (id: string) => void;
persistRecipe: () => Promise<void>;
openPreviewDialog: () => void;
runPreview: () => Promise<void>;
runPreview: () => Promise<boolean>;
copyRecipe: () => Promise<void>;
importRecipe: (value: string) => string | null;
};
@ -72,6 +79,23 @@ function toErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
function normalizeDatasetRows(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(row): row is Record<string, unknown> =>
typeof row === "object" && row !== null && !Array.isArray(row),
);
}
function normalizeObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
@ -109,6 +133,7 @@ export function useRecipeStudioActions({
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
onPreviewSuccess,
}: UseRecipeStudioActionsParams): UseRecipeStudioActionsResult {
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
@ -120,6 +145,10 @@ export function useRecipeStudioActions({
const [previewRows, setPreviewRows] = useState(5);
const [previewErrors, setPreviewErrors] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const [executions, setExecutions] = useState<RecipeExecutionRecord[]>([]);
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(
null,
);
const normalizedWorkflowName = useMemo(
() => normalizeNonEmptyName(workflowName, "Unnamed"),
@ -143,6 +172,7 @@ export function useRecipeStudioActions({
setCopied(false);
setPreviewErrors([]);
setPreviewDialogOpen(false);
setSelectedExecutionId(null);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
@ -163,6 +193,43 @@ export function useRecipeStudioActions({
resetRecipe,
]);
useEffect(() => {
let cancelled = false;
setExecutions([]);
setSelectedExecutionId(null);
async function loadExecutions(): Promise<void> {
try {
const records = await listRecipeExecutions(recipeId);
if (cancelled) {
return;
}
setExecutions(records);
setSelectedExecutionId(records[0]?.id ?? null);
} catch (error) {
console.error("Load recipe executions failed:", error);
}
}
void loadExecutions();
return () => {
cancelled = true;
};
}, [recipeId]);
const upsertExecution = useCallback((record: RecipeExecutionRecord): void => {
setExecutions((current) => {
const next = current.filter((item) => item.id !== record.id);
next.unshift(record);
return next;
});
setSelectedExecutionId(record.id);
void saveRecipeExecution(record).catch((error) => {
console.error("Save recipe execution failed:", error);
});
}, []);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
@ -210,15 +277,30 @@ export function useRecipeStudioActions({
setPreviewDialogOpen(true);
}
const runPreview = useCallback(async (): Promise<void> => {
setPreviewLoading(true);
const runPreview = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
setPreviewLoading(false);
return;
return false;
}
setPreviewLoading(true);
const createdAt = Date.now();
const baseExecution: RecipeExecutionRecord = {
id: crypto.randomUUID(),
recipeId,
kind: "preview",
status: "running",
rows: previewRows,
createdAt,
recipeSignature: currentSignature,
dataset: [],
analysis: null,
processor_artifacts: null,
error: null,
};
upsertExecution(baseExecution);
const previewPayload = {
...payload,
@ -234,24 +316,58 @@ export function useRecipeStudioActions({
const errors = validation.errors.map((item) => item.message);
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
upsertExecution({
...baseExecution,
status: "error",
error: nextErrors[0],
});
setPreviewErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
return;
return false;
}
await previewRecipe(previewPayload);
const result = await previewRecipe(previewPayload);
upsertExecution({
...baseExecution,
status: "completed",
dataset: normalizeDatasetRows(result.dataset),
analysis: normalizeObject(result.analysis),
processor_artifacts: normalizeObject(result.processor_artifacts),
error: null,
});
setPreviewDialogOpen(false);
setPreviewErrors([]);
toastSuccess(`Preview generated (${previewRows} rows).`);
onPreviewSuccess?.();
return true;
} catch (error) {
console.error("Preview failed:", error);
const message = toErrorMessage(error, "Preview request failed.");
upsertExecution({
...baseExecution,
status: "error",
error: message,
});
setPreviewErrors([message]);
toastError("Preview failed", message);
return false;
} finally {
setPreviewLoading(false);
}
}, [payloadErrorMessage, payloadResult.errors, previewRows, readPayload]);
}, [
currentSignature,
onPreviewSuccess,
payloadErrorMessage,
payloadResult.errors,
previewRows,
readPayload,
recipeId,
upsertExecution,
]);
const selectExecution = useCallback((id: string): void => {
setSelectedExecutionId(id);
}, []);
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
@ -302,6 +418,10 @@ export function useRecipeStudioActions({
setPreviewRows,
previewErrors,
previewLoading,
currentSignature,
executions,
selectedExecutionId,
setSelectedExecutionId: selectExecution,
persistRecipe,
openPreviewDialog,
runPreview,

View file

@ -23,6 +23,7 @@ import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/re
import { BlockSheet } from "./components/block-sheet";
import { LayoutControls } from "./components/controls/layout-controls";
import { ViewportControls } from "./components/controls/viewport-controls";
import { ExecutionsView } from "./components/executions/executions-view";
import { InternalsSync } from "./components/graph/internals-sync";
import { RecipeStudioHeader } from "./components/recipe-studio-header";
import { RecipeNode } from "./components/recipe-graph-node";
@ -51,6 +52,7 @@ import {
buildDialogOptions,
buildPreviewSummary,
} from "./utils/recipe-studio-view";
import type { RecipeStudioView } from "./execution-types";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
@ -155,6 +157,7 @@ export function RecipeStudioPage({
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
null,
);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
@ -269,6 +272,10 @@ export function RecipeStudioPage({
setPreviewRows,
previewErrors,
previewLoading,
currentSignature,
executions,
selectedExecutionId,
setSelectedExecutionId,
persistRecipe,
openPreviewDialog,
runPreview,
@ -284,6 +291,9 @@ export function RecipeStudioPage({
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
onPreviewSuccess: () => {
setActiveView("executions");
},
});
const openProcessorsFromSheet = useCallback(() => {
@ -305,72 +315,85 @@ export function RecipeStudioPage({
ref={setSheetContainer}
>
<RecipeStudioHeader
activeView={activeView}
previewLoading={previewLoading}
saveLoading={saveLoading}
saveTone={saveTone}
savedAtLabel={savedAtLabel}
workflowName={workflowName}
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onPreview={openPreviewDialog}
onSaveRecipe={() => {
void persistRecipe();
}}
/>
<div className="h-[75vh] w-full rounded-t-none">
<ReactFlow
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { path: "auto" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}
fitView={true}
className="h-full w-full rounded-t-none"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddSeed={addSeedNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={copyRecipe}
onImport={() => setImportOpen(true)}
{activeView === "editor" ? (
<ReactFlow
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { path: "auto" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}
fitView={true}
className="h-full w-full rounded-t-none"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
</Panel>
<ViewportControls
interactive={interactive}
onToggleInteractive={toggleInteractive}
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddSeed={addSeedNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={copyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
<ViewportControls
interactive={interactive}
onToggleInteractive={toggleInteractive}
/>
</ReactFlow>
) : (
<ExecutionsView
executions={executions}
selectedExecutionId={selectedExecutionId}
currentSignature={currentSignature}
previewLoading={previewLoading}
onSelectExecution={setSelectedExecutionId}
onRunPreview={openPreviewDialog}
/>
</ReactFlow>
)}
</div>
</div>
</main>