feat(recipe-studio): persist advanced collapsible states across components and sessions

This commit is contained in:
Shine1i 2026-03-05 11:56:40 +01:00
commit bffda3a479
8 changed files with 106 additions and 11 deletions

View file

@ -20,7 +20,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject, useMemo, useState } from "react";
import { type ReactElement, type RefObject, useMemo } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { LlmConfig } from "../../types";
import { isLikelyImageValue } from "../../utils/image-preview";
@ -151,7 +151,7 @@ export function LlmGeneralTab({
}, [imageColumnOptions, imageContext.column_name, seedColumns]);
const traceModeId = `${config.id}-trace-mode`;
const reasoningToggleId = `${config.id}-reasoning-content`;
const [advancedOpen, setAdvancedOpen] = useState(false);
const advancedOpen = config.advancedOpen === true;
return (
<div className="space-y-4">
@ -358,7 +358,10 @@ export function LlmGeneralTab({
</p>
)}
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible
open={advancedOpen}
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"

View file

@ -44,7 +44,7 @@ export function CategoryDialog({
onUpdate,
}: CategoryDialogProps): ReactElement {
const [conditionDraft, setConditionDraft] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const advancedOpen = config.advancedOpen === true;
const conditionInputId = `${config.id}-conditional-rule`;
const conditional = config.conditional_params ?? {};
const conditionalCount = Object.keys(conditional).length;
@ -112,7 +112,10 @@ export function CategoryDialog({
/>
</div>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible
open={advancedOpen}
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"

View file

@ -212,7 +212,7 @@ async function toUnstructuredUploadFile(file: File): Promise<File> {
export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactElement {
const [inspectError, setInspectError] = useState<string | null>(null);
const [isInspecting, setIsInspecting] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const advancedOpen = config.advancedOpen === true;
const [previewRows, setPreviewRows] = useState<Record<string, unknown>[]>([]);
const [expandedPreviewRows, setExpandedPreviewRows] = useState<Record<number, boolean>>({});
const [localFile, setLocalFile] = useState<File | null>(null);
@ -618,7 +618,10 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
</div>
)}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible
open={advancedOpen}
onOpenChange={(openState) => onUpdate({ advancedOpen: openState })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"

View file

@ -11,7 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type ReactElement, useMemo, useState } from "react";
import { type ReactElement, useMemo } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ValidatorConfig } from "../../types";
import { isValidatorCodeLang } from "../../utils/validators/code-lang";
@ -32,7 +32,7 @@ export function ValidatorDialog({
const configs = useRecipeStudioStore((state) => state.configs);
const targetColumnId = `${config.id}-target-column`;
const batchSizeId = `${config.id}-batch-size`;
const [advancedOpen, setAdvancedOpen] = useState(false);
const advancedOpen = config.advancedOpen === true;
const codeOptions = useMemo(
() =>
Object.values(configs)
@ -106,7 +106,10 @@ export function ValidatorDialog({
</p>
)}
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<Collapsible
open={advancedOpen}
onOpenChange={(open) => onUpdate({ advancedOpen: open })}
>
<CollapsibleTrigger asChild={true}>
<button
type="button"

View file

@ -70,6 +70,8 @@ export type CategoryConditionalParams = {
export type SamplerConfig = {
id: string;
kind: "sampler";
// ui-only
advancedOpen?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: SamplerType;
name: string;
@ -174,6 +176,8 @@ export type LlmTraceType = "none" | "last_message" | "all_messages";
export type LlmConfig = {
id: string;
kind: "llm";
// ui-only
advancedOpen?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: LlmType;
name: string;
@ -248,6 +252,8 @@ export type ExpressionConfig = {
export type ValidatorConfig = {
id: string;
kind: "validator";
// ui-only
advancedOpen?: boolean;
name: string;
drop?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
@ -272,6 +278,8 @@ export type MarkdownNoteConfig = {
export type SeedConfig = {
id: string;
kind: "seed";
// ui-only
advancedOpen?: boolean;
name: string;
drop?: boolean;
// ui-only: explicit per-column drop for structured seed sources (hf/local)

View file

@ -5,7 +5,10 @@ import type {
MarkdownNoteConfig,
NodeConfig,
RecipeProcessorConfig,
SeedConfig,
SamplerConfig,
SeedSourceType,
ValidatorConfig,
} from "../../types";
import { buildEdges } from "./edges";
import { isRecord, parseJson, readString } from "./helpers";
@ -39,6 +42,7 @@ type UiInput = {
unstructured_file_name?: unknown;
unstructured_chunk_size?: unknown;
unstructured_chunk_overlap?: unknown;
advanced_open_by_node?: unknown;
};
type UiMarkdownNoteNode = {
@ -256,6 +260,42 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
return noteNodes;
}
function parseAdvancedOpenByNode(input: unknown): Record<string, boolean> {
if (!isRecord(input)) {
return {};
}
const out: Record<string, boolean> = {};
for (const [nameRaw, value] of Object.entries(input)) {
const name = nameRaw.trim();
if (!name || typeof value !== "boolean") {
continue;
}
out[name] = value;
}
return out;
}
type AdvancedOpenConfig = LlmConfig | SamplerConfig | SeedConfig | ValidatorConfig;
function isAdvancedOpenConfig(config: NodeConfig): config is AdvancedOpenConfig {
return (
config.kind === "llm" ||
config.kind === "sampler" ||
config.kind === "seed" ||
config.kind === "validator"
);
}
function applyAdvancedOpen(
config: NodeConfig,
advancedOpenByNode: Record<string, boolean>,
): void {
if (!isAdvancedOpenConfig(config)) {
return;
}
config.advancedOpen = advancedOpenByNode[config.name] === true;
}
function attachLlmTooling(
config: LlmConfig,
toolConfigsByAlias: Map<string, LlmToolConfig>,
@ -336,6 +376,7 @@ export function importRecipePayload(input: string): ImportResult {
const uiUnstructuredChunkOverlap = readStringNumber(
ui?.unstructured_chunk_overlap,
);
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node);
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
for (const note of uiMarkdownNotes) {
@ -364,7 +405,9 @@ export function importRecipePayload(input: string): ImportResult {
preferredSourceType: uiSeedSourceType,
seed_columns: uiSeedColumns,
seed_drop_columns:
uiSeedDropColumns ?? payloadSeedDropColumns,
uiSeedDropColumns && uiSeedDropColumns.length > 0
? uiSeedDropColumns
: payloadSeedDropColumns,
seed_preview_rows: uiSeedPreviewRows,
local_file_name: uiLocalFileName,
unstructured_file_name: uiUnstructuredFileName,
@ -372,6 +415,7 @@ export function importRecipePayload(input: string): ImportResult {
unstructured_chunk_overlap: uiUnstructuredChunkOverlap,
});
if (seedConfig) {
applyAdvancedOpen(seedConfig, uiAdvancedOpenByNode);
if (nameToId.has(seedConfig.name)) {
errors.push(`Duplicate column name: ${seedConfig.name}.`);
} else {
@ -441,6 +485,7 @@ export function importRecipePayload(input: string): ImportResult {
if (config.kind === "llm") {
attachLlmTooling(config, toolConfigsByAlias, mcpProvidersByName);
}
applyAdvancedOpen(config, uiAdvancedOpenByNode);
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
return;

View file

@ -66,6 +66,29 @@ function pushUniqueJson(
}
}
function collectAdvancedOpenByNode(
configs: Record<string, NodeConfig>,
): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const config of Object.values(configs)) {
if (
!(
config.kind === "sampler" ||
config.kind === "llm" ||
config.kind === "validator" ||
config.kind === "seed"
)
) {
continue;
}
if (config.advancedOpen !== true) {
continue;
}
out[config.name] = true;
}
return out;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
export function buildRecipePayload(
configs: Record<string, NodeConfig>,
@ -333,6 +356,7 @@ export function buildRecipePayload(
if (seedDropProcessor) {
recipeProcessors.push(seedDropProcessor);
}
const uiAdvancedOpenByNode = collectAdvancedOpenByNode(configs);
return {
errors,
@ -386,6 +410,10 @@ export function buildRecipePayload(
firstSeed.unstructured_chunk_overlap !== undefined && {
unstructured_chunk_overlap: firstSeed.unstructured_chunk_overlap,
}),
...(Object.keys(uiAdvancedOpenByNode).length > 0 && {
// biome-ignore lint/style/useNamingConvention: ui schema
advanced_open_by_node: uiAdvancedOpenByNode,
}),
},
},
};

View file

@ -67,6 +67,8 @@ export type RecipePayload = {
unstructured_file_name?: string;
unstructured_chunk_size?: string;
unstructured_chunk_overlap?: string;
// ui-only: per-node advanced accordion state
advanced_open_by_node?: Record<string, boolean>;
};
};