feat(recipe-studio): polish import/llm editors, refs preview, copy toast, and note layout behavior
This commit is contained in:
parent
a53d8cb272
commit
75857cdcea
7 changed files with 131 additions and 65 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue