feat(recipe-studio): polish import/llm editors, refs preview, copy toast, and note layout behavior

This commit is contained in:
Shine1i 2026-02-26 11:44:57 +01:00
commit 75857cdcea
7 changed files with 131 additions and 65 deletions

View file

@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
@ -11,6 +10,7 @@ import type { ReactElement } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { getAvailableVariableEntries } from "../../utils/variables";
import { AvailableReferencesInline } from "../shared/available-references-inline";
import { InlineField } from "./inline-field";
type InlineExpressionProps = {
@ -58,26 +58,7 @@ export function InlineExpression({
/>
</InlineField>
</div>
{vars.length > 0 && (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">Available references</p>
<div className="flex flex-wrap gap-1">
{vars.map((v) => (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={
v.source === "seed"
? "corner-squircle h-4 border-blue-500/25 bg-blue-500/10 px-1.5 font-mono text-[10px] text-blue-700 dark:text-blue-300"
: "corner-squircle h-4 px-1.5 font-mono text-[10px]"
}
>
{v.name}
</Badge>
))}
</div>
</div>
)}
<AvailableReferencesInline entries={vars} />
</div>
);
}

View file

@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@ -15,6 +14,7 @@ import type { LlmConfig, Score, ScoreOption } from "../types";
import { AUX_HANDLE_CLASS } from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariableEntries } from "../utils/variables";
import { AvailableReferencesInline } from "./shared/available-references-inline";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
type PromptField = "prompt" | "system_prompt";
@ -59,27 +59,7 @@ function updateOptionAt(
function AuxVariableBadges({ llmId }: { llmId: string }): ReactElement | null {
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, llmId);
if (vars.length === 0) return null;
return (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">Available references</p>
<div className="flex flex-wrap gap-1">
{vars.map((v) => (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={
v.source === "seed"
? "corner-squircle h-4 border-blue-500/25 bg-blue-500/10 px-1.5 font-mono text-[10px] text-blue-700 dark:text-blue-300"
: "corner-squircle h-4 px-1.5 font-mono text-[10px]"
}
>
{v.name}
</Badge>
))}
</div>
</div>
);
return <AvailableReferencesInline entries={vars} />;
}
function AuxNodeBase({
@ -144,7 +124,7 @@ function AuxNodeBase({
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<Textarea
className="corner-squircle nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag nowheel max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
@ -216,7 +196,7 @@ function AuxNodeBase({
onChange={(event) => updateScore({ name: event.target.value })}
/>
<Textarea
className="corner-squircle nodrag max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag nowheel max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
placeholder="Score description"
value={score.description}
onChange={(event) => updateScore({ description: event.target.value })}

View file

@ -0,0 +1,120 @@
import { Badge } from "@/components/ui/badge";
import { type ReactElement, useLayoutEffect, useRef, useState } from "react";
import type { AvailableVariableEntry } from "../../utils/variables";
type AvailableReferencesInlineProps = {
entries: AvailableVariableEntry[];
};
const MAX_ROWS = 2;
export function AvailableReferencesInline({
entries,
}: AvailableReferencesInlineProps): ReactElement | null {
const [expanded, setExpanded] = useState(false);
const [collapsedCount, setCollapsedCount] = useState(entries.length);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRefs = useRef<Array<HTMLSpanElement | null>>([]);
useLayoutEffect(() => {
if (expanded) {
return;
}
const wrapper = wrapperRef.current;
const items = measureRefs.current.filter(
(node): node is HTMLSpanElement => Boolean(node),
);
if (!(wrapper && items.length > 0)) {
setCollapsedCount(entries.length);
return;
}
const compute = () => {
const rowTops: number[] = [];
let cutoff = items.length;
for (let i = 0; i < items.length; i += 1) {
const top = items[i].offsetTop;
if (!rowTops.some((value) => Math.abs(value - top) <= 1)) {
rowTops.push(top);
}
if (rowTops.length > MAX_ROWS) {
cutoff = i;
break;
}
}
if (cutoff < items.length) {
cutoff = Math.max(0, cutoff - 1);
}
setCollapsedCount(cutoff);
};
compute();
const observer = new ResizeObserver(compute);
observer.observe(wrapper);
return () => observer.disconnect();
}, [entries.length, expanded]);
if (entries.length === 0) {
return null;
}
const shown = expanded ? entries : entries.slice(0, collapsedCount);
const hiddenCount = Math.max(0, entries.length - shown.length);
return (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">
Available references
</p>
<div ref={wrapperRef} className="relative">
{!expanded && (
<div className="invisible pointer-events-none absolute inset-0 -z-10">
<div className="flex flex-wrap gap-1">
{entries.map((entry, index) => (
<Badge
// biome-ignore lint/suspicious/noArrayIndexKey: static measurement mirror
key={`${entry.source}:${entry.name}:${index}`}
ref={(node) => {
measureRefs.current[index] = node;
}}
variant="secondary"
className={
entry.source === "seed"
? "corner-squircle h-4 border-blue-500/25 bg-blue-500/10 px-1.5 font-mono text-[10px] text-blue-700 dark:text-blue-300"
: "corner-squircle h-4 px-1.5 font-mono text-[10px]"
}
>
{entry.name}
</Badge>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-1">
{shown.map((entry) => (
<Badge
key={`${entry.source}:${entry.name}`}
variant="secondary"
className={
entry.source === "seed"
? "corner-squircle h-4 border-blue-500/25 bg-blue-500/10 px-1.5 font-mono text-[10px] text-blue-700 dark:text-blue-300"
: "corner-squircle h-4 px-1.5 font-mono text-[10px]"
}
>
{entry.name}
</Badge>
))}
{!expanded && hiddenCount > 0 && (
<button
type="button"
className="corner-squircle h-4 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(true)}
>
+{hiddenCount} more
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -63,7 +63,7 @@ export function ImportDialog({
/>
<Textarea
id={payloadId}
className="corner-squircle nodrag min-h-[220px]"
className="corner-squircle nodrag min-h-[220px] max-h-[450px]"
placeholder='{"recipe": { "columns": [] }}'
value={value}
onChange={(event) => setValue(event.target.value)}

View file

@ -147,7 +147,7 @@ export function LlmGeneralTab({
/>
<Textarea
id={promptId}
className="corner-squircle nodrag max-h-[600px] overflow-auto"
className="corner-squircle nodrag max-h-[450px] overflow-auto"
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
@ -177,7 +177,7 @@ export function LlmGeneralTab({
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[600px] overflow-auto"
className="corner-squircle nodrag max-h-[450px] overflow-auto"
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>

View file

@ -167,7 +167,7 @@ export function useRecipePersistence({
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
toastSuccess("Payload copied");
toastSuccess("👨‍🍳 Recipe copied");
} catch (error) {
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");

View file

@ -258,11 +258,6 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
applyLayout: () =>
set((state) => {
const isTopBottom = state.layoutDirection === "TB";
const noteNodeIds = new Set(
Object.values(state.configs)
.filter((config) => config.kind === "markdown_note")
.map((config) => config.id),
);
const displayGraph = deriveDisplayGraph({
nodes: state.nodes,
@ -272,14 +267,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
auxNodePositions: {},
llmAuxVisibility: state.llmAuxVisibility,
});
const layoutNodes = displayGraph.nodes.filter(
(node) => !noteNodeIds.has(node.id),
);
const layoutNodeIds = new Set(layoutNodes.map((node) => node.id));
const layoutEdges = displayGraph.edges.filter(
(edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target),
);
const { nodes } = getLayoutedElements(layoutNodes, layoutEdges, {
const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
@ -288,9 +276,6 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
nodes.map((node) => [node.id, node.position] as const),
);
const nextNodes = state.nodes.map((node) => {
if (noteNodeIds.has(node.id)) {
return node;
}
const position = layoutedPositions.get(node.id);
if (!position) {
return node;