inline sizes, aux nodes resize, and auto layout keep nodes close logic

This commit is contained in:
shine1i 2026-02-06 13:18:18 +01:00
commit 58b40a9015
16 changed files with 568 additions and 226 deletions

View file

@ -34,7 +34,8 @@ Owns:
- source-of-truth state (`configs`, `nodes`, `edges`, `processors`)
- mutation entrypoints (`updateConfig`, `onConnect`, `onNodesChange`, etc)
- selection/dialog state (`selectConfig`, `openConfig`)
- aux node position persistence
- aux node position persistence (`auxNodePositions`)
- aux node size persistence (`auxNodeSizes`)
Helper module:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/stores/canvas-lab-helpers.ts`
@ -77,7 +78,7 @@ Owns:
- dialog router per block type
Notes:
- registry passes `modelConfigAliases` into `LlmDialog`
- registry receives dialog option lists from page and forwards to block dialogs
- avoids dialog -> store dependency cycle
Do not place here:
@ -93,6 +94,7 @@ Files:
Owns:
- contract mapping between UI state and backend payload
- edge inference fallback when import payload has no `ui.edges`
- node width persistence via `ui.nodes[].width`
Do not place here:
- ReactFlow render logic
@ -135,6 +137,13 @@ Dialog flow:
- node click -> `selectConfig` (no forced modal)
- node `Details` button -> `openConfig`
Node sizing:
- default builder/aux node width is `400px`
- users can resize builder + aux nodes
- resized width is kept in canvas state and round-tripped through import/export
- sizing constants live in:
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/constants.ts`
## 4) UI Mode Policy (Inline vs Dialog)
File:
@ -185,6 +194,9 @@ Rules:
Aux nodes:
- are UI projections, not new payload schema entities
- have independent drag positions persisted in Zustand `auxNodePositions`
- have independent sizes persisted in Zustand `auxNodeSizes`
- are resizable (same hidden-control resize UX as builder nodes)
- are re-anchored near parent nodes after auto-layout/direction change
## 7) Connect Rules (single source of truth)
@ -211,9 +223,9 @@ Connect side-effects:
Current safe flow:
- store state -> page (`configs`)
- page derives `modelConfigAliases`
- page passes aliases -> `ConfigDialog`
- dialog passes aliases -> registry -> `LlmDialog`
- page derives dialog option lists (`modelConfigAliases`, `modelProviderOptions`, `datetimeOptions`)
- page passes these options -> `ConfigDialog`
- dialog passes options -> registry -> block dialogs (`LlmDialog`, `ModelConfigDialog`, `TimedeltaDialog`)
No dialog component should import store directly.

View file

@ -130,6 +130,7 @@ export function CanvasLabPage(): ReactElement {
nodes,
edges,
auxNodePositions,
auxNodeSizes,
configs,
processors,
sheetView,
@ -154,12 +155,15 @@ export function CanvasLabPage(): ReactElement {
setLayoutDirection,
applyLayout,
setAuxNodePosition,
setAuxNodeSize,
syncAuxNodePositions,
syncAuxNodeSizes,
} = useCanvasLabStore(
useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
configs: state.configs,
processors: state.processors,
sheetView: state.sheetView,
@ -184,7 +188,9 @@ export function CanvasLabPage(): ReactElement {
setLayoutDirection: state.setLayoutDirection,
applyLayout: state.applyLayout,
setAuxNodePosition: state.setAuxNodePosition,
setAuxNodeSize: state.setAuxNodeSize,
syncAuxNodePositions: state.syncAuxNodePositions,
syncAuxNodeSizes: state.syncAuxNodeSizes,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
@ -215,8 +221,9 @@ export function CanvasLabPage(): ReactElement {
configs,
layoutDirection,
auxNodePositions,
auxNodeSizes,
});
}, [auxNodePositions, configs, edges, layoutDirection, nodes]);
}, [auxNodePositions, auxNodeSizes, configs, edges, layoutDirection, nodes]);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
@ -224,6 +231,9 @@ export function CanvasLabPage(): ReactElement {
useEffect(() => {
syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults);
}, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]);
useEffect(() => {
syncAuxNodeSizes(displayGraph.auxNodeIds);
}, [displayGraph.auxNodeIds, syncAuxNodeSizes]);
const handleNodeClick = useCallback(
(_: unknown, node: Node<CanvasNodeData | CanvasAuxNodeData>) => {
@ -240,13 +250,25 @@ export function CanvasLabPage(): ReactElement {
for (const change of changes) {
if (
!("id" in change) ||
change.type !== "position" ||
!change.id.startsWith("aux-") ||
!change.position
!change.id.startsWith("aux-")
) {
continue;
}
setAuxNodePosition(change.id, change.position);
if (change.type === "position" && change.position) {
setAuxNodePosition(change.id, change.position);
continue;
}
if (
change.type === "dimensions" &&
change.dimensions &&
change.dimensions.width > 0 &&
change.dimensions.height > 0
) {
setAuxNodeSize(change.id, {
width: change.dimensions.width,
height: change.dimensions.height,
});
}
}
const next = changes.filter(
(change): change is NodeChange<CanvasBuilderNode> =>
@ -256,7 +278,7 @@ export function CanvasLabPage(): ReactElement {
onNodesChange(next);
}
},
[baseNodeIds, onNodesChange, setAuxNodePosition],
[baseNodeIds, onNodesChange, setAuxNodePosition, setAuxNodeSize],
);
const handleEdgesChange = useCallback(

View file

@ -1,8 +1,15 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import {
Handle,
NodeResizer,
Position,
type Node,
type NodeProps,
} from "@xyflow/react";
import { memo, type ReactElement } from "react";
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
import { HANDLE_IDS } from "../utils/handles";
@ -49,7 +56,9 @@ function updateOptionAt(
);
}
function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | null {
function AuxNodeBase({
data,
}: NodeProps<CanvasAuxNodeType>): ReactElement | null {
const config = useCanvasLabStore((state) => state.configs[data.llmId]);
const updateConfig = useCanvasLabStore((state) => state.updateConfig);
@ -63,13 +72,25 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
return (
<BaseNode className="corner-squircle min-w-[250px] rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={520}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="px-3 py-2">
<Textarea
className="nodrag min-h-[78px] text-xs"
className="nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
@ -128,7 +149,19 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
};
return (
<BaseNode className="corner-squircle min-w-[280px] rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={640}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
@ -139,13 +172,13 @@ function AuxNodeBase({ data }: NodeProps<CanvasAuxNodeType>): ReactElement | nul
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<Input
className="nodrag h-7 text-xs"
className="nodrag h-7 w-full text-xs"
placeholder="Score name"
value={score.name}
onChange={(event) => updateScore({ name: event.target.value })}
/>
<Textarea
className="nodrag min-h-[56px] text-xs"
className="nodrag 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

@ -26,6 +26,7 @@ import {
type NodeProps,
} from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type {
CanvasNode as CanvasNodeType,
@ -270,7 +271,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
return (
<div className="space-y-1 pb-1">
{items.map((item) => (
<div key={item.id} className="pointer-events-none relative pl-3">
<div key={item.id} className="pointer-events-none relative min-w-0 pl-3">
<Handle
id={item.id}
type="target"
@ -278,7 +279,9 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
className="pointer-events-auto !size-2 !border-border !bg-background"
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
<span className="block truncate text-[10px] text-muted-foreground">
{item.label}
</span>
</div>
))}
</div>
@ -318,12 +321,12 @@ function CanvasNodeBase({
const llmInputHandles = getLlmInputHandleItems(config);
return (
<BaseNode className="corner-squircle relative min-w-[260px] overflow-visible rounded-lg border-border/60 shadow-sm">
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
<NodeResizer
isVisible={selected}
minWidth={260}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={760}
maxWidth={MAX_NODE_WIDTH}
maxHeight={520}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"

View file

@ -8,6 +8,7 @@ import {
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { InlineField } from "./inline-field";
type InlineExpressionProps = {
config: ExpressionConfig;
@ -21,30 +22,34 @@ export function InlineExpression({
onUpdate,
}: InlineExpressionProps): ReactElement {
return (
<div className="grid grid-cols-[110px_1fr] gap-2">
<Select
value={config.dtype}
onValueChange={(value) =>
onUpdate({ dtype: value as ExpressionDtype })
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectValue placeholder="dtype" />
</SelectTrigger>
<SelectContent>
{DTYPE_OPTIONS.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="nodrag h-7 text-xs"
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
/>
<div className="grid gap-3 sm:grid-cols-[130px_1fr]">
<InlineField label="Output type">
<Select
value={config.dtype}
onValueChange={(value) =>
onUpdate({ dtype: value as ExpressionDtype })
}
>
<SelectTrigger className="nodrag h-8 w-full text-xs">
<SelectValue placeholder="dtype" />
</SelectTrigger>
<SelectContent>
{DTYPE_OPTIONS.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
</InlineField>
<InlineField label="Expression">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
/>
</InlineField>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
import type { ReactElement, ReactNode } from "react";
type InlineFieldProps = {
label: string;
className?: string;
children: ReactNode;
};
export function InlineField({
label,
className,
children,
}: InlineFieldProps): ReactElement {
return (
<div className={cn("grid gap-1.5", className)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
{children}
</div>
);
}

View file

@ -8,6 +8,7 @@ import {
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { LlmConfig } from "../../types";
import { InlineField } from "./inline-field";
type InlineLlmProps = {
config: LlmConfig;
@ -37,39 +38,43 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement {
const isCode = config.llm_type === "code";
return (
<div className="grid gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Model alias"
value={config.model_alias}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: event.target.value,
})
}
/>
{isCode && (
<Select
value={config.code_lang?.trim() || "python"}
onValueChange={(value) =>
<div className="space-y-3">
<InlineField label="Model alias">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="Model alias"
value={config.model_alias}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: value,
model_alias: event.target.value,
})
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</InlineField>
{isCode && (
<InlineField label="Code language">
<Select
value={config.code_lang?.trim() || "python"}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: value,
})
}
>
<SelectTrigger className="nodrag h-8 w-full text-xs">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</InlineField>
)}
<p className="text-[11px] text-muted-foreground">
Prompt/System are edited in dialog or linked input nodes.

View file

@ -1,6 +1,7 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { ModelConfig, ModelProviderConfig } from "../../types";
import { InlineField } from "./inline-field";
type InlineModelPatch = Partial<ModelProviderConfig> | Partial<ModelConfig>;
@ -12,54 +13,64 @@ type InlineModelProps = {
export function InlineModel(props: InlineModelProps): ReactElement {
if (props.config.kind === "model_provider") {
return (
<div className="grid grid-cols-2 gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Provider type"
value={props.config.provider_type}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: event.target.value,
})
}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Endpoint"
value={props.config.endpoint}
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<InlineField label="Provider type">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="openai-compatible"
value={props.config.provider_type}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: event.target.value,
})
}
/>
</InlineField>
<InlineField label="Endpoint">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="https://api.example.com/v1"
value={props.config.endpoint}
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
/>
</InlineField>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
placeholder="Provider"
value={props.config.provider}
onChange={(event) => props.onUpdate({ provider: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
placeholder="Model"
value={props.config.model}
onChange={(event) => props.onUpdate({ model: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Temp"
value={props.config.inference_temperature ?? ""}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: event.target.value,
})
}
/>
<div className="grid gap-3 sm:grid-cols-2">
<InlineField label="Provider">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="provider alias"
value={props.config.provider}
onChange={(event) => props.onUpdate({ provider: event.target.value })}
/>
</InlineField>
<InlineField label="Model">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="gpt-4o-mini"
value={props.config.model}
onChange={(event) => props.onUpdate({ model: event.target.value })}
/>
</InlineField>
<InlineField label="Temperature" className="sm:col-span-2">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
placeholder="0.7"
value={props.config.inference_temperature ?? ""}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: event.target.value,
})
}
/>
</InlineField>
</div>
);
}

View file

@ -8,6 +8,7 @@ import {
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { InlineField } from "./inline-field";
type InlineSamplerProps = {
config: SamplerConfig;
@ -30,7 +31,7 @@ function ConvertToField({
onValueChange(next === "none" ? undefined : (next as ConvertTo))
}
>
<SelectTrigger className="nodrag h-7 text-xs">
<SelectTrigger className="nodrag h-8 w-full text-xs">
<SelectValue placeholder="Convert" />
</SelectTrigger>
<SelectContent>
@ -49,92 +50,108 @@ export function InlineSampler({
}: InlineSamplerProps): ReactElement | null {
if (config.sampler_type === "uniform") {
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Low"
value={config.low ?? ""}
onChange={(event) => onUpdate({ low: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="High"
value={config.high ?? ""}
onChange={(event) => onUpdate({ high: event.target.value })}
/>
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
<div className="grid gap-3 sm:grid-cols-3">
<InlineField label="Low">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
placeholder="0"
value={config.low ?? ""}
onChange={(event) => onUpdate({ low: event.target.value })}
/>
</InlineField>
<InlineField label="High">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
placeholder="100"
value={config.high ?? ""}
onChange={(event) => onUpdate({ high: event.target.value })}
/>
</InlineField>
<InlineField label="Convert to">
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
</InlineField>
</div>
);
}
if (config.sampler_type === "gaussian") {
return (
<div className="grid grid-cols-3 gap-2">
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Mean"
value={config.mean ?? ""}
onChange={(event) => onUpdate({ mean: event.target.value })}
/>
<Input
className="nodrag h-7 text-xs"
type="number"
placeholder="Std"
value={config.std ?? ""}
onChange={(event) => onUpdate({ std: event.target.value })}
/>
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
<div className="grid gap-3 sm:grid-cols-3">
<InlineField label="Mean">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
placeholder="0"
value={config.mean ?? ""}
onChange={(event) => onUpdate({ mean: event.target.value })}
/>
</InlineField>
<InlineField label="Std dev">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
placeholder="1"
value={config.std ?? ""}
onChange={(event) => onUpdate({ std: event.target.value })}
/>
</InlineField>
<InlineField label="Convert to">
<ConvertToField
value={config.convert_to}
onValueChange={(value) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: value,
})
}
/>
</InlineField>
</div>
);
}
if (config.sampler_type === "bernoulli") {
return (
<Input
className="nodrag h-7 text-xs"
type="number"
min="0"
max="1"
step="0.01"
placeholder="p"
value={config.p ?? ""}
onChange={(event) => onUpdate({ p: event.target.value })}
/>
<InlineField label="Probability (p)">
<Input
className="nodrag h-8 w-full text-xs"
type="number"
min="0"
max="1"
step="0.01"
placeholder="0.5"
value={config.p ?? ""}
onChange={(event) => onUpdate({ p: event.target.value })}
/>
</InlineField>
);
}
if (config.sampler_type === "uuid") {
return (
<Input
className="nodrag h-7 text-xs"
placeholder="UUID format"
value={config.uuid_format ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: event.target.value,
})
}
/>
<InlineField label="UUID format">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="uuid4"
value={config.uuid_format ?? ""}
onChange={(event) =>
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: event.target.value,
})
}
/>
</InlineField>
);
}

View file

@ -0,0 +1,4 @@
export const DEFAULT_NODE_WIDTH = 400;
export const DEFAULT_NODE_HEIGHT = 120;
export const MIN_NODE_WIDTH = 260;
export const MAX_NODE_WIDTH = 900;

View file

@ -1,4 +1,5 @@
import { type Edge, addEdge } from "@xyflow/react";
import { DEFAULT_NODE_WIDTH } from "../constants";
import type {
CanvasNode,
LayoutDirection,
@ -61,6 +62,7 @@ export function buildNodeUpdate(
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config, layoutDirection),
style: { width: DEFAULT_NODE_WIDTH },
selected: true,
};
const mode = getConfigUiMode(config);

View file

@ -39,6 +39,7 @@ type CanvasLabState = {
nodes: CanvasNode[];
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
configs: Record<string, NodeConfig>;
processors: CanvasProcessorConfig[];
sheetView: SheetView;
@ -62,10 +63,15 @@ type CanvasLabState = {
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
loadCanvas: (snapshot: CanvasSnapshot) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
setAuxNodeSize: (
id: string,
size: { width: number; height: number },
) => void;
syncAuxNodePositions: (
activeIds: string[],
defaults: Record<string, XYPosition>,
) => void;
syncAuxNodeSizes: (activeIds: string[]) => void;
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
@ -76,6 +82,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nodes: [],
edges: [],
auxNodePositions: {},
auxNodeSizes: {},
configs: {},
processors: [],
sheetView: "root",
@ -92,6 +99,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
auxNodePositions: {},
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@ -107,6 +115,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
ranksep: isTopBottom ? 140 : 80,
});
return {
auxNodePositions: {},
nodes: applyLayoutDirectionToNodes(
nodes,
state.configs,
@ -187,6 +196,7 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nextId: snapshot.nextId,
nextY: snapshot.nextY,
auxNodePositions: {},
auxNodeSizes: {},
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
@ -204,6 +214,21 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
},
};
}),
setAuxNodeSize: (id, size) =>
set((state) => {
const width = Math.max(1, size.width);
const height = Math.max(1, size.height);
const current = state.auxNodeSizes[id];
if (current && current.width === width && current.height === height) {
return state;
}
return {
auxNodeSizes: {
...state.auxNodeSizes,
[id]: { width, height },
},
};
}),
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const nextPositions: Record<string, XYPosition> = {};
@ -232,6 +257,29 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
}
return state;
}),
syncAuxNodeSizes: (activeIds) =>
set((state) => {
const activeSet = new Set(activeIds);
const nextSizes: Record<string, { width: number; height: number }> = {};
for (const [id, size] of Object.entries(state.auxNodeSizes)) {
if (activeSet.has(id)) {
nextSizes[id] = size;
}
}
const prevIds = Object.keys(state.auxNodeSizes);
const nextIds = Object.keys(nextSizes);
if (prevIds.length !== nextIds.length) {
return { auxNodeSizes: nextSizes };
}
for (const id of nextIds) {
const prev = state.auxNodeSizes[id];
const next = nextSizes[id];
if (!(prev && prev.width === next.width && prev.height === next.height)) {
return { auxNodeSizes: nextSizes };
}
}
return state;
}),
updateConfig: (id, patch) => {
const applyUpdate = (state: CanvasLabState) => {
const current = state.configs[id];

View file

@ -1,5 +1,6 @@
import type { Edge, Node, XYPosition } from "@xyflow/react";
import type { CanvasAuxNodeData } from "../../components/canvas-aux-node";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
import type { CanvasNode, LayoutDirection, NodeConfig } from "../../types";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../handles";
@ -9,6 +10,7 @@ type DisplayGraphInput = {
configs: Record<string, NodeConfig>;
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
};
export type DisplayGraph = {
@ -24,19 +26,78 @@ type AuxNodeItem = {
data: CanvasAuxNodeData;
};
function getNodeWidth(node: Node): number {
if (typeof node.width === "number" && Number.isFinite(node.width)) {
return node.width;
}
if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) {
return node.style.width;
}
if (typeof node.style?.width === "string") {
const parsed = Number.parseFloat(node.style.width);
if (Number.isFinite(parsed)) {
return parsed;
}
}
if (
typeof node.measured?.width === "number" &&
Number.isFinite(node.measured.width)
) {
return node.measured.width;
}
return DEFAULT_NODE_WIDTH;
}
function getNodeHeight(node: Node): number {
if (typeof node.height === "number" && Number.isFinite(node.height)) {
return node.height;
}
if (typeof node.style?.height === "number" && Number.isFinite(node.style.height)) {
return node.style.height;
}
if (typeof node.style?.height === "string") {
const parsed = Number.parseFloat(node.style.height);
if (Number.isFinite(parsed)) {
return parsed;
}
}
if (
typeof node.measured?.height === "number" &&
Number.isFinite(node.measured.height)
) {
return node.measured.height;
}
return DEFAULT_NODE_HEIGHT;
}
export function deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
auxNodeSizes,
}: DisplayGraphInput): DisplayGraph {
const displayNodes = nodes.map((node) => {
const hasWidth =
typeof node.width === "number" ||
typeof node.style?.width === "number" ||
(typeof node.style?.width === "string" &&
Number.isFinite(Number.parseFloat(node.style.width)));
if (hasWidth) {
return node;
}
return {
...node,
style: { ...node.style, width: DEFAULT_NODE_WIDTH },
};
});
const auxNodes: Node<CanvasAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
for (const node of nodes) {
for (const node of displayNodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
@ -91,35 +152,102 @@ export function deriveDisplayGraph({
continue;
}
const itemSpan = 140;
const itemCenterOffset = ((items.length - 1) * itemSpan) / 2;
const horizontalSpan = 300;
const horizontalCenterOffset = ((items.length - 1) * horizontalSpan) / 2;
items.forEach((item, index) => {
const parentWidth = getNodeWidth(node);
const parentHeight = getNodeHeight(node);
const itemsWithLayout = items.map((item) => {
const auxId = `aux-${node.id}-${item.key}`;
const defaultPosition =
llmDirection === "TB"
? {
x: node.position.x + index * horizontalSpan - horizontalCenterOffset,
y: node.position.y - 210,
}
: {
x: node.position.x - 330,
y: node.position.y + index * itemSpan - itemCenterOffset,
};
const position = auxNodePositions[auxId] ?? defaultPosition;
const savedSize = auxNodeSizes[auxId];
return {
item,
auxId,
width: savedSize?.width ?? DEFAULT_NODE_WIDTH,
height: savedSize?.height ?? DEFAULT_NODE_HEIGHT,
};
});
auxNodeIds.push(auxId);
if (!auxNodePositions[auxId]) {
auxDefaults[auxId] = defaultPosition;
const gap = 24;
const sideOffset = 48;
if (llmDirection === "TB") {
const totalWidth =
itemsWithLayout.reduce((sum, entry) => sum + entry.width, 0) +
(itemsWithLayout.length - 1) * gap;
const startX = node.position.x + (parentWidth - totalWidth) / 2;
let xCursor = startX;
for (const entry of itemsWithLayout) {
const defaultPosition = {
x: xCursor,
y: node.position.y - entry.height - sideOffset,
};
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
xCursor += entry.width + gap;
auxNodeIds.push(entry.auxId);
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
auxNodes.push({
id: entry.auxId,
type: "aux",
data: entry.item.data,
position,
style: {
width: entry.width,
height: entry.height,
},
draggable: true,
selectable: true,
focusable: true,
connectable: false,
});
auxEdges.push({
id: `e-${entry.auxId}-${node.id}`,
source: entry.auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: entry.item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
}
continue;
}
const totalHeight =
itemsWithLayout.reduce((sum, entry) => sum + entry.height, 0) +
(itemsWithLayout.length - 1) * gap;
const maxWidth = Math.max(...itemsWithLayout.map((entry) => entry.width));
const baseX = node.position.x - maxWidth - sideOffset;
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
for (const entry of itemsWithLayout) {
const defaultPosition = {
x: baseX + (maxWidth - entry.width),
y: yCursor,
};
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
yCursor += entry.height + gap;
auxNodeIds.push(entry.auxId);
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
auxNodes.push({
id: auxId,
id: entry.auxId,
type: "aux",
data: item.data,
data: entry.item.data,
position,
style: {
width: entry.width,
height: entry.height,
},
draggable: true,
selectable: true,
focusable: true,
@ -127,22 +255,22 @@ export function deriveDisplayGraph({
});
auxEdges.push({
id: `e-${auxId}-${node.id}`,
source: auxId,
id: `e-${entry.auxId}-${node.id}`,
source: entry.auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: item.targetHandle,
targetHandle: entry.item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
style: { strokeWidth: 1.5, stroke: "var(--border)" },
});
});
}
}
return {
nodes: [...nodes, ...auxNodes],
nodes: [...displayNodes, ...auxNodes],
edges: [...edges, ...auxEdges],
auxNodeIds,
auxDefaults,

View file

@ -149,8 +149,8 @@ export function importCanvasPayload(input: string): ImportResult {
return { errors, snapshot: null };
}
const { positions, edges: uiEdges } = parseUi(ui);
const nodes = buildNodes(configs, positions);
const { layouts, edges: uiEdges } = parseUi(ui);
const nodes = buildNodes(configs, layouts);
const edges = buildEdges(configs, nameToId, uiEdges);
const maxY = nodes.reduce(

View file

@ -1,4 +1,5 @@
import type { CanvasNode, NodeConfig } from "../../types";
import { DEFAULT_NODE_WIDTH } from "../../constants";
import { nodeDataFromConfig } from "../index";
import { isRecord, readString } from "./helpers";
@ -10,10 +11,10 @@ type UiInput = {
export function parseUi(
ui: UiInput | null,
): {
positions: Map<string, { x: number; y: number }>;
layouts: Map<string, { x: number; y: number; width?: number }>;
edges: Array<{ from: string; to: string; type?: string }> | null;
} {
const positions = new Map<string, { x: number; y: number }>();
const layouts = new Map<string, { x: number; y: number; width?: number }>();
const edges: Array<{ from: string; to: string; type?: string }> = [];
if (ui && Array.isArray(ui.nodes)) {
for (const node of ui.nodes) {
@ -21,8 +22,13 @@ export function parseUi(
const id = readString(node.id);
const x = typeof node.x === "number" ? node.x : null;
const y = typeof node.y === "number" ? node.y : null;
const width = typeof node.width === "number" ? node.width : null;
if (id && x !== null && y !== null) {
positions.set(id, { x, y });
layouts.set(id, {
x,
y,
...(width && width > 0 ? { width } : {}),
});
}
}
}
@ -42,21 +48,26 @@ export function parseUi(
}
}
}
return { positions, edges: edges.length > 0 ? edges : null };
return { layouts, edges: edges.length > 0 ? edges : null };
}
export function buildNodes(
configs: NodeConfig[],
positions: Map<string, { x: number; y: number }>,
layouts: Map<string, { x: number; y: number; width?: number }>,
): CanvasNode[] {
return configs.map((config, index) => {
const position =
positions.get(config.name) ?? ({ x: 0, y: index * 140 } as const);
const fallbackLayout: { x: number; y: number; width?: number } = {
x: 0,
y: index * 140,
};
const layout =
layouts.get(config.name) ?? fallbackLayout;
return {
id: config.id,
type: "builder",
position,
position: { x: layout.x, y: layout.y },
data: nodeDataFromConfig(config),
style: { width: layout.width ?? DEFAULT_NODE_WIDTH },
};
});
}

View file

@ -25,6 +25,22 @@ import {
validateUsedProviders,
} from "./validate";
function getNodeWidth(node: CanvasNode): number | null {
if (typeof node.width === "number" && Number.isFinite(node.width)) {
return node.width;
}
if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) {
return node.style.width;
}
if (typeof node.style?.width === "string") {
const parsed = Number.parseFloat(node.style.width);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
export function buildCanvasPayload(
configs: Record<string, NodeConfig>,
@ -100,11 +116,13 @@ export function buildCanvasPayload(
if (!config) {
return [];
}
const width = getNodeWidth(node);
return [
{
id: config.name,
x: node.position.x,
y: node.position.y,
...(width !== null ? { width } : {}),
},
];
});