feat(recipe-studio): add support for naming full runs, enhance empty states, and refine UI components

This commit is contained in:
Shine1i 2026-03-03 21:56:37 +01:00
commit 6997919c65
16 changed files with 192 additions and 73 deletions

View file

@ -15,6 +15,7 @@ import {
type Database02Icon,
DragDropVerticalIcon,
PlusSignIcon,
Search01Icon,
Tick02Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
@ -373,12 +374,18 @@ export function BlockSheet({
)}
<SheetTitle>{sheetTitle}</SheetTitle>
</div>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search blocks..."
className="corner-squircle mt-3 h-9"
/>
<div className="relative mt-3">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search blocks..."
className="corner-squircle h-9 pl-8"
/>
</div>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">

View file

@ -2,7 +2,11 @@ import type { ReactElement } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { RecipeExecutionRecord } from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import {
executionLabel,
isExecutionInProgress,
normalizeRunName,
} from "../../executions/execution-helpers";
import {
formatStatus,
formatTimestamp,
@ -34,43 +38,50 @@ export function ExecutionSidebar({
No executions yet.
</div>
) : (
executions.map((execution) => (
<button
key={execution.id}
type="button"
onClick={() => onSelectExecution(execution.id)}
className={cn(
"w-full rounded-xl corner-squircle border border-r-2 border-border/60 bg-card/60 p-3 text-left transition-colors",
selectedExecutionId === execution.id
? "border-primary/35 bg-primary/[0.045]"
: "hover:bg-muted/25",
statusRightBorder(execution.status),
)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium capitalize">
{execution.kind}
</p>
<Badge
variant="outline"
className={cn("capitalize text-[11px]", statusTone(execution.status))}
>
{formatStatus(execution.status)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{execution.rows} rows</p>
{isExecutionInProgress(execution.status) &&
typeof execution.batch?.total === "number" &&
execution.batch.total > 1 && (
<p className="text-xs text-muted-foreground">
Batch {execution.batch.idx ?? "--"}/{execution.batch.total}
</p>
executions.map((execution) => {
const title =
execution.kind === "full"
? (normalizeRunName(execution.run_name) ??
executionLabel(execution.kind))
: executionLabel(execution.kind);
return (
<button
key={execution.id}
type="button"
onClick={() => onSelectExecution(execution.id)}
className={cn(
"w-full rounded-xl corner-squircle border border-r-2 border-border/60 bg-card/60 p-3 text-left transition-colors",
selectedExecutionId === execution.id
? "border-primary/35 bg-primary/[0.045]"
: "hover:bg-muted/25",
statusRightBorder(execution.status),
)}
<p className="text-xs text-muted-foreground">
{formatTimestamp(execution.createdAt)}
</p>
</button>
))
>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium">
{title}
</p>
<Badge
variant="outline"
className={cn("capitalize text-[11px]", statusTone(execution.status))}
>
{formatStatus(execution.status)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{execution.rows} rows</p>
{isExecutionInProgress(execution.status) &&
typeof execution.batch?.total === "number" &&
execution.batch.total > 1 && (
<p className="text-xs text-muted-foreground">
Batch {execution.batch.idx ?? "--"}/{execution.batch.total}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatTimestamp(execution.createdAt)}
</p>
</button>
);
})
)}
</div>
</aside>

View file

@ -1,4 +1,11 @@
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement } from "react";
@ -91,14 +98,26 @@ export function LlmScoresTab({
label="Scorers"
hint="Rubrics used by LLM Judge to score each generated row."
/>
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add scorer
</Button>
{scores.length > 0 && (
<Button type="button" size="xs" variant="outline" onClick={addScore}>
Add scorer
</Button>
)}
</div>
{scores.length === 0 && (
<p className="text-xs text-muted-foreground">
Add at least one scorer.
</p>
<Empty className="rounded-xl border border-dashed border-border/70 p-5">
<EmptyHeader>
<EmptyTitle className="text-sm">No scorers yet</EmptyTitle>
<EmptyDescription className="text-xs">
Add a scorer rubric before running judge generation.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="max-w-none">
<Button type="button" size="sm" onClick={addScore}>
Add first scorer
</Button>
</EmptyContent>
</Empty>
)}
{scores.map((score, index) => (
<div

View file

@ -46,6 +46,7 @@ export function ModelConfigDialog({
return (
<div className="space-y-4">
<NameField
label="Model alias"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>

View file

@ -35,6 +35,7 @@ export function ModelProviderDialog({
return (
<div className="space-y-4">
<NameField
label="Provider name"
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>

View file

@ -26,6 +26,8 @@ type RunDialogProps = {
kind: RecipeExecutionKind;
onKindChange: (kind: RecipeExecutionKind) => void;
rows: number;
fullRunName: string;
onFullRunNameChange: (name: string) => void;
onRowsChange: (rows: number) => void;
settings: RecipeRunSettings;
onSettingsChange: (patch: Partial<RecipeRunSettings>) => void;
@ -207,6 +209,8 @@ export function RunDialog({
kind,
onKindChange,
rows,
fullRunName,
onFullRunNameChange,
onRowsChange,
settings,
onSettingsChange,
@ -304,6 +308,23 @@ export function RunDialog({
/>
</div>
{kind === "full" && (
<div className="grid gap-2">
<FieldLabel
label="Run name"
htmlFor="run-name"
hint="Optional label shown in executions list."
/>
<Input
id="run-name"
type="text"
value={fullRunName}
onChange={(event) => onFullRunNameChange(event.target.value)}
placeholder="Sprint dataset v2"
/>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<FieldLabel label="Records" htmlFor="run-rows" hint={rowHint} />

View file

@ -102,6 +102,29 @@ function truncatePreviewValue(value: string): string {
return `${value.slice(0, PREVIEW_TRUNCATE_AT)}`;
}
function getPreviewEmptyStateCopy(mode: SeedConfig["seed_source_type"]): {
title: string;
description: string;
} {
if (mode === "local") {
return {
title: "No local preview yet",
description: "Choose a CSV/JSON/JSONL file, then click Load to fetch 10 rows.",
};
}
if (mode === "unstructured") {
return {
title: "No chunk preview yet",
description:
"Choose a TXT/PDF/DOCX file, then click Load to extract + preview chunk_text rows.",
};
}
return {
title: "No dataset preview yet",
description: "Pick a Hugging Face dataset and click Load to fetch 10 sample rows.",
};
}
function parseChunkNumber(
value: string | undefined,
fallback: number,
@ -196,6 +219,7 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
const [unstructuredFile, setUnstructuredFile] = useState<File | null>(null);
const mode = config.seed_source_type ?? "hf";
const previewEmpty = getPreviewEmptyStateCopy(mode);
useEffect(() => {
setInspectError(null);
@ -753,12 +777,14 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
<div className="flex w-full items-center justify-center">
<Empty className="max-w-lg">
<EmptyHeader>
<EmptyTitle>Seed preview</EmptyTitle>
<EmptyTitle>{previewEmpty.title}</EmptyTitle>
<EmptyDescription>
Use the load button next to the source input to fetch 10 rows.
{previewEmpty.description}
</EmptyDescription>
</EmptyHeader>
<EmptyContent />
<EmptyContent className="text-xs text-muted-foreground">
Preview appears here after loading source metadata.
</EmptyContent>
</Empty>
</div>
) : (

View file

@ -6,6 +6,7 @@ type NameFieldProps = {
id?: string;
value: string;
onChange: (value: string) => void;
label?: string;
hint?: string;
};
@ -13,6 +14,7 @@ export function NameField({
id,
value,
onChange,
label,
hint,
}: NameFieldProps): ReactElement {
const fallbackId = useId();
@ -20,7 +22,7 @@ export function NameField({
return (
<div className="grid gap-2">
<FieldLabel
label="Column name"
label={label ?? "Column name"}
htmlFor={inputId}
hint={
hint ??

View file

@ -44,6 +44,9 @@ export type RecipeExecutionRecord = {
// biome-ignore lint/style/useNamingConvention: backend schema
jobId: string | null;
kind: RecipeExecutionKind;
// ui-only display label for full runs
// biome-ignore lint/style/useNamingConvention: ui schema
run_name: string | null;
status: RecipeExecutionStatus;
rows: number;
createdAt: number;

View file

@ -89,6 +89,14 @@ export function executionLabel(kind: "preview" | "full"): string {
return kind === "preview" ? "Preview" : "Full run";
}
export function normalizeRunName(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function executionSortWeight(status: RecipeExecutionStatus): number {
if (isExecutionInProgress(status)) {
return 0;
@ -133,6 +141,7 @@ export function withExecutionDefaults(
return {
...record,
run_name: normalizeRunName(record.run_name),
dataset,
log_lines: logLines,
datasetTotal,

View file

@ -114,6 +114,7 @@ export function createBaseExecutionRecord(input: {
kind: RecipeExecutionKind;
rows: number;
currentSignature: string;
runName?: string | null;
}): RecipeExecutionRecord {
const createdAt = Date.now();
return {
@ -121,6 +122,7 @@ export function createBaseExecutionRecord(input: {
recipeId: input.recipeId,
jobId: null,
kind: input.kind,
run_name: input.runName ?? null,
status: "pending",
rows: input.rows,
createdAt,

View file

@ -15,6 +15,7 @@ import type {
import {
DATASET_PAGE_SIZE,
executionLabel,
normalizeRunName,
normalizeDatasetRows,
toErrorMessage,
withExecutionDefaults,
@ -50,8 +51,10 @@ type UseRecipeExecutionsResult = {
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
fullRunName: string;
setPreviewRows: (rows: number) => void;
setFullRows: (rows: number) => void;
setFullRunName: (name: string) => void;
runErrors: string[];
runSettings: RecipeRunSettings;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
@ -109,6 +112,7 @@ export function useRecipeExecutions({
runDialogKind,
previewRows,
fullRows,
fullRunName,
runErrors,
runSettings,
previewLoading,
@ -119,6 +123,7 @@ export function useRecipeExecutions({
setRunDialogKind,
setPreviewRows,
setFullRows,
setFullRunName,
setRunErrors,
setRunSettings,
setPreviewLoading,
@ -133,6 +138,7 @@ export function useRecipeExecutions({
runDialogKind: state.runDialogKind,
previewRows: state.previewRows,
fullRows: state.fullRows,
fullRunName: state.fullRunName,
runErrors: state.runErrors,
runSettings: state.runSettings,
previewLoading: state.previewLoading,
@ -143,6 +149,7 @@ export function useRecipeExecutions({
setRunDialogKind: state.setRunDialogKind,
setPreviewRows: state.setPreviewRows,
setFullRows: state.setFullRows,
setFullRunName: state.setFullRunName,
setRunErrors: state.setRunErrors,
setRunSettings: state.setRunSettings,
setPreviewLoading: state.setPreviewLoading,
@ -238,10 +245,12 @@ export function useRecipeExecutions({
payload: RecipePayload;
rows: number;
settings: RecipeRunSettings;
runName: string | null;
}): Promise<boolean> => {
const { kind, payload, rows, settings } = input;
const { kind, payload, rows, settings, runName } = input;
const setLoading = kind === "preview" ? setPreviewLoading : setFullLoading;
const label = executionLabel(kind);
const normalizedRunName = kind === "full" ? normalizeRunName(runName) : null;
setLoading(true);
const baseExecution = createBaseExecutionRecord({
@ -249,6 +258,7 @@ export function useRecipeExecutions({
kind,
rows,
currentSignature,
runName: normalizedRunName,
});
upsertAndPersist(baseExecution);
@ -309,7 +319,11 @@ export function useRecipeExecutions({
);
const runWithValidation = useCallback(
async (kind: RecipeExecutionKind, rows: number): Promise<boolean> => {
async (
kind: RecipeExecutionKind,
rows: number,
runName: string | null,
): Promise<boolean> => {
const payload = readExecutablePayload();
if (!payload) {
return false;
@ -345,18 +359,19 @@ export function useRecipeExecutions({
payload,
rows: normalizedRows,
settings: runSettings,
runName,
});
},
[readExecutablePayload, runExecution, runSettings, setRunErrors],
);
const runPreview = useCallback(async (): Promise<boolean> => {
return runWithValidation("preview", previewRows);
return runWithValidation("preview", previewRows, null);
}, [previewRows, runWithValidation]);
const runFull = useCallback(async (): Promise<boolean> => {
return runWithValidation("full", fullRows);
}, [fullRows, runWithValidation]);
return runWithValidation("full", fullRows, fullRunName);
}, [fullRows, fullRunName, runWithValidation]);
const runFromDialog = useCallback(async (): Promise<boolean> => {
setValidateResult(null);
@ -509,8 +524,10 @@ export function useRecipeExecutions({
setRunDialogOpen,
previewRows,
fullRows,
fullRunName,
setPreviewRows,
setFullRows,
setFullRunName,
runErrors,
runSettings,
setRunSettings,

View file

@ -48,8 +48,10 @@ type UseRecipeStudioActionsResult = {
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
fullRunName: string;
setPreviewRows: (rows: number) => void;
setFullRows: (rows: number) => void;
setFullRunName: (name: string) => void;
runErrors: string[];
runSettings: RecipeRunSettings;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
@ -125,8 +127,10 @@ export function useRecipeStudioActions({
setRunDialogOpen: executions.setRunDialogOpen,
previewRows: executions.previewRows,
fullRows: executions.fullRows,
fullRunName: executions.fullRunName,
setPreviewRows: executions.setPreviewRows,
setFullRows: executions.setFullRows,
setFullRunName: executions.setFullRunName,
runErrors: executions.runErrors,
runSettings: executions.runSettings,
setRunSettings: executions.setRunSettings,

View file

@ -261,8 +261,10 @@ export function RecipeStudioPage({
setRunDialogOpen,
previewRows,
fullRows,
fullRunName,
setPreviewRows,
setFullRows,
setFullRunName,
runErrors,
runSettings,
setRunSettings,
@ -652,6 +654,8 @@ export function RecipeStudioPage({
kind={runDialogKind}
onKindChange={setRunDialogKind}
rows={runDialogRows}
fullRunName={fullRunName}
onFullRunNameChange={setFullRunName}
onRowsChange={(rows) => {
if (runDialogKind === "preview") {
setPreviewRows(rows);

View file

@ -34,6 +34,7 @@ type RecipeExecutionsState = {
runDialogKind: RecipeExecutionKind;
previewRows: number;
fullRows: number;
fullRunName: string;
runErrors: string[];
runSettings: RecipeRunSettings;
previewLoading: boolean;
@ -44,6 +45,7 @@ type RecipeExecutionsState = {
setRunDialogKind: (kind: RecipeExecutionKind) => void;
setPreviewRows: (rows: number) => void;
setFullRows: (rows: number) => void;
setFullRunName: (name: string) => void;
setRunErrors: (errors: string[]) => void;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
setPreviewLoading: (loading: boolean) => void;
@ -59,6 +61,7 @@ const INITIAL_STATE = {
runDialogKind: "preview",
previewRows: 5,
fullRows: 1000,
fullRunName: "",
runErrors: [],
runSettings: DEFAULT_RUN_SETTINGS,
previewLoading: false,
@ -71,6 +74,7 @@ const INITIAL_STATE = {
| "runDialogKind"
| "previewRows"
| "fullRows"
| "fullRunName"
| "runErrors"
| "runSettings"
| "previewLoading"
@ -87,6 +91,7 @@ export const useRecipeExecutionsStore = create<RecipeExecutionsState>((set) => (
set({ previewRows: Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1 }),
setFullRows: (rows) =>
set({ fullRows: Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1 }),
setFullRunName: (name) => set({ fullRunName: name }),
setRunErrors: (errors) => set({ runErrors: errors }),
setRunSettings: (patch) =>
set((state) => ({

View file

@ -216,20 +216,7 @@ export function makeLlmConfig(
with_trace: "none",
// biome-ignore lint/style/useNamingConvention: api schema
extract_reasoning_content: false,
scores:
llmType === "judge"
? [
{
name: "Quality",
description: "Overall quality based on the criteria.",
options: [
{ value: "1", description: "Poor" },
{ value: "3", description: "Acceptable" },
{ value: "5", description: "Excellent" },
],
},
]
: undefined,
scores: llmType === "judge" ? [] : undefined,
};
}
@ -268,7 +255,7 @@ export function makeModelConfig(
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: "0.7",
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: "256",
inference_max_tokens: "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: "",
// biome-ignore lint/style/useNamingConvention: api schema