fix(frontend/builder): handle discriminated unions and improve node layout (#12354)

## Summary
- **Discriminated union support (oneOf)**: Added a new `OneOfField`
component that properly
renders Pydantic discriminated unions. Hides the unusable parent object
handle, auto-populates
the discriminator value, shows a dropdown with variant titles (e.g.,
"Username" / "UserId"), and
filters out the internal discriminator field from the form.
Non-discriminated `oneOf` schemas
  fall back to existing `AnyOfField` behavior.
- **Collapsible object outputs**: Object-type outputs with nested keys
(e.g.,
`PersonLookupResponse.Url`, `PersonLookupResponse.profile`) are now
collapsed by default behind a
caret toggle. Nested keys show short names instead of the full
`Parent.Key` prefix.
- **Node layout cleanup**: Removed excessive bottom margin (`mb-6`) from
`FormRenderer`, hide the
Advanced toggle when no advanced fields exist, and add rounded bottom
corners on OUTPUT-type
  blocks.

<img width="440" height="427" alt="Screenshot 2026-03-10 at 11 31 55 AM"
src="https://github.com/user-attachments/assets/06cc5414-4e02-4371-bdeb-1695e7cb2c97"
/>
<img width="371" height="320" alt="Screenshot 2026-03-10 at 11 36 52 AM"
src="https://github.com/user-attachments/assets/1a55f87a-c602-4f4d-b91b-6e49f810e5d5"
/>

  ## Test plan
- [x] Add a Twitter Get User block — verify "Identifier" shows a
dropdown (Username/UserId) with
no unusable parent handle, discriminator field is hidden, and the block
can run without staying
  INCOMPLETE
- [x] Add any block with object outputs (e.g., PersonLookupResponse) —
verify nested keys are
  collapsed by default and expand on click with short labels
- [x] Verify blocks without advanced fields don't show the Advanced
toggle
- [x] Verify existing `anyOf` schemas (optional types, 3+ variant
unions) still render correctly
  - [x] Check OUTPUT-type blocks have rounded bottom corners

---------

Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
Co-authored-by: eureka928 <meobius123@gmail.com>
This commit is contained in:
Abhimanyu Yadav
2026-03-10 19:43:32 +05:30
committed by GitHub
parent 6a6b23c2e1
commit 684845d946
10 changed files with 357 additions and 23 deletions

View File

@@ -23,6 +23,12 @@ import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
import { useCustomNode } from "./useCustomNode";
function hasAdvancedFields(schema: RJSFSchema): boolean {
const properties = schema?.properties;
if (!properties) return false;
return Object.values(properties).some((prop: any) => prop.advanced === true);
}
export type CustomNodeData = {
hardcodedValues: {
[key: string]: any;
@@ -108,7 +114,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
)}
showHandles={showHandles}
/>
<NodeAdvancedToggle nodeId={nodeId} />
<NodeAdvancedToggle
nodeId={nodeId}
isLastSection={data.uiType === BlockUIType.OUTPUT}
hasAdvancedFields={hasAdvancedFields(inputSchema)}
/>
{data.uiType != BlockUIType.OUTPUT && (
<OutputHandler
uiType={data.uiType}

View File

@@ -2,18 +2,33 @@ import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CaretDownIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
type Props = {
nodeId: string;
isLastSection?: boolean;
hasAdvancedFields?: boolean;
};
export function NodeAdvancedToggle({ nodeId }: Props) {
export function NodeAdvancedToggle({
nodeId,
isLastSection,
hasAdvancedFields = true,
}: Props) {
const showAdvanced = useNodeStore(
(state) => state.nodeAdvancedStates[nodeId] || false,
);
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
if (!hasAdvancedFields) return null;
return (
<div className="flex items-center justify-start gap-2 bg-white px-5 pb-3.5">
<div
className={cn(
"flex items-center justify-start gap-2 bg-white px-5 pb-3.5",
isLastSection && "rounded-b-xlarge",
)}
>
<Button
variant="ghost"
className="h-fit min-w-0 p-0 hover:border-transparent hover:bg-transparent"

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CaretRightIcon, InfoIcon } from "@phosphor-icons/react";
import { RJSFSchema } from "@rjsf/utils";
import { useState } from "react";
@@ -30,13 +30,41 @@ export const OutputHandler = ({
const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(true);
const brokenOutputs = useBrokenOutputs(nodeId);
const [expandedObjects, setExpandedObjects] = useState<
Record<string, boolean>
>({});
const showHandles = uiType !== BlockUIType.OUTPUT;
function toggleObjectExpanded(key: string) {
setExpandedObjects((prev) => ({ ...prev, [key]: !prev[key] }));
}
function hasConnectedOrBrokenDescendant(
schema: RJSFSchema,
keyPrefix: string,
): boolean {
if (!schema) return false;
return Object.entries(schema).some(
([key, fieldSchema]: [string, RJSFSchema]) => {
const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key;
if (isOutputConnected(nodeId, fullKey) || brokenOutputs.has(fullKey))
return true;
if (fieldSchema?.properties)
return hasConnectedOrBrokenDescendant(
fieldSchema.properties,
fullKey,
);
return false;
},
);
}
const renderOutputHandles = (
schema: RJSFSchema,
keyPrefix: string = "",
titlePrefix: string = "",
connectedOnly: boolean = false,
): React.ReactNode[] => {
return Object.entries(schema).map(
([key, fieldSchema]: [string, RJSFSchema]) => {
@@ -44,10 +72,23 @@ export const OutputHandler = ({
const fieldTitle = titlePrefix + (fieldSchema?.title || key);
const isConnected = isOutputConnected(nodeId, fullKey);
const shouldShow = isConnected || isOutputVisible;
const isBroken = brokenOutputs.has(fullKey);
const hasNestedProperties = !!fieldSchema?.properties;
const selfIsRelevant = isConnected || isBroken;
const descendantIsRelevant =
hasNestedProperties &&
hasConnectedOrBrokenDescendant(fieldSchema.properties!, fullKey);
const shouldShow = connectedOnly
? selfIsRelevant || descendantIsRelevant
: isOutputVisible || selfIsRelevant || descendantIsRelevant;
const { displayType, colorClass, hexColor } =
getTypeDisplayInfo(fieldSchema);
const isBroken = brokenOutputs.has(fullKey);
const isExpanded = expandedObjects[fullKey] ?? false;
// User expanded → show all children; auto-expanded → filter to connected only
const shouldRenderChildren = isExpanded || descendantIsRelevant;
return shouldShow ? (
<div
@@ -56,6 +97,19 @@ export const OutputHandler = ({
data-tutorial-id={`output-handler-${nodeId}-${fieldTitle}`}
>
<div className="relative flex items-center gap-2">
{hasNestedProperties && (
<button
onClick={() => toggleObjectExpanded(fullKey)}
className="flex items-center text-slate-500 hover:text-slate-700"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<CaretDownIcon size={12} weight="bold" />
) : (
<CaretRightIcon size={12} weight="bold" />
)}
</button>
)}
{fieldSchema?.description && (
<TooltipProvider>
<Tooltip>
@@ -102,12 +156,14 @@ export const OutputHandler = ({
)}
</div>
{/* Recursively render nested properties */}
{fieldSchema?.properties &&
{/* Nested properties */}
{hasNestedProperties &&
shouldRenderChildren &&
renderOutputHandles(
fieldSchema.properties,
fieldSchema.properties!,
fullKey,
`${fieldTitle}.`,
"",
!isExpanded,
)}
</div>
) : null;
@@ -136,7 +192,7 @@ export const OutputHandler = ({
</Button>
<div className="flex flex-col items-end gap-2">
{renderOutputHandles(properties)}
{renderOutputHandles(properties, "", "", !isOutputVisible)}
</div>
</div>
);

View File

@@ -34,10 +34,7 @@ export function FormRenderer({
}, [preprocessedSchema, uiSchema]);
return (
<div
className={cn("mb-6 mt-4", className)}
data-tutorial-id="input-handles"
>
<div className={cn("mt-4", className)} data-tutorial-id="input-handles">
<Form
formContext={formContext}
idPrefix="agpt"

View File

@@ -63,7 +63,6 @@ export const useAnyOfField = (props: FieldProps) => {
);
const handlePrefix = cleanUpHandleId(field_id);
console.log("handlePrefix", handlePrefix);
useEdgeStore
.getState()
.removeEdgesByHandlePrefix(registry.formContext.nodeId, handlePrefix);

View File

@@ -4,6 +4,7 @@ import {
TemplatesType,
} from "@rjsf/utils";
import { AnyOfField } from "./anyof/AnyOfField";
import { OneOfField } from "./oneof/OneOfField";
import {
ArrayFieldItemTemplate,
ArrayFieldTemplate,
@@ -32,6 +33,7 @@ const NoButton = () => null;
export function generateBaseFields(): RegistryFieldsType {
return {
AnyOfField,
OneOfField,
ArraySchemaField,
};
}

View File

@@ -0,0 +1,243 @@
import {
descriptionId,
FieldProps,
getTemplate,
getUiOptions,
getWidget,
} from "@rjsf/utils";
import { useEffect, useRef, useState } from "react";
import { AnyOfField } from "../anyof/AnyOfField";
import { cleanUpHandleId, getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
function getDiscriminatorPropName(schema: any): string | undefined {
if (!schema?.discriminator) return undefined;
if (typeof schema.discriminator === "string") return schema.discriminator;
return schema.discriminator.propertyName;
}
export function OneOfField(props: FieldProps) {
const { schema } = props;
const discriminatorProp = getDiscriminatorPropName(schema);
if (!discriminatorProp) {
return <AnyOfField {...props} />;
}
return (
<DiscriminatedUnionField {...props} discriminatorProp={discriminatorProp} />
);
}
interface DiscriminatedUnionFieldProps extends FieldProps {
discriminatorProp: string;
}
function DiscriminatedUnionField({
discriminatorProp,
...props
}: DiscriminatedUnionFieldProps) {
const { schema, registry, formData, onChange, name } = props;
const { fields, schemaUtils, formContext } = registry;
const { SchemaField } = fields;
const { nodeId } = formContext;
const field_id = props.fieldPathId.$id;
// Resolve variant schemas from $refs
const variants = useRef(
(schema.oneOf || []).map((opt: any) =>
schemaUtils.retrieveSchema(opt, formData),
),
);
// Build dropdown options from variant titles and discriminator const values
const enumOptions = variants.current.map((variant: any, index: number) => {
const discValue = (variant.properties?.[discriminatorProp] as any)?.const;
return {
value: index,
label: variant.title || discValue || `Option ${index + 1}`,
discriminatorValue: discValue,
};
});
// Determine initial selected index from formData
function getInitialIndex() {
const currentDisc = formData?.[discriminatorProp];
if (currentDisc) {
const idx = enumOptions.findIndex(
(o) => o.discriminatorValue === currentDisc,
);
if (idx >= 0) return idx;
}
return 0;
}
const [selectedIndex, setSelectedIndex] = useState(getInitialIndex);
// Generate handleId for sub-fields (same convention as AnyOfField)
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
const handleId = getHandleId({
uiOptions,
id: field_id + ANY_OF_FLAG,
schema,
});
const childUiSchema = updateUiOption(props.uiSchema, {
handleId,
label: false,
fromAnyOf: true,
});
// Get selected variant schema with discriminator property filtered out
// and sub-fields inheriting the parent's advanced value
const selectedVariant = variants.current[selectedIndex];
const parentAdvanced = (schema as any).advanced;
function getFilteredSchema() {
if (!selectedVariant?.properties) return selectedVariant;
const filteredProperties: Record<string, any> = {};
for (const [key, value] of Object.entries(selectedVariant.properties)) {
if (key === discriminatorProp) continue;
filteredProperties[key] =
parentAdvanced !== undefined
? { ...(value as any), advanced: parentAdvanced }
: value;
}
return {
...selectedVariant,
properties: filteredProperties,
required: (selectedVariant.required || []).filter(
(r: string) => r !== discriminatorProp,
),
};
}
const filteredSchema = getFilteredSchema();
// Handle variant change
function handleVariantChange(option?: string) {
const newIndex = option !== undefined ? parseInt(option, 10) : -1;
if (newIndex === selectedIndex || newIndex < 0) return;
const newVariant = variants.current[newIndex];
const oldVariant = variants.current[selectedIndex];
const discValue = (newVariant.properties?.[discriminatorProp] as any)
?.const;
// Clean edges for this field
const handlePrefix = cleanUpHandleId(field_id);
useEdgeStore.getState().removeEdgesByHandlePrefix(nodeId, handlePrefix);
// Sanitize current data against old→new schema to preserve shared fields
let newFormData = schemaUtils.sanitizeDataForNewSchema(
newVariant,
oldVariant,
formData,
);
// Fill in defaults for the new variant
newFormData = schemaUtils.getDefaultFormState(
newVariant,
newFormData,
"excludeObjectChildren",
) as any;
newFormData = { ...newFormData, [discriminatorProp]: discValue };
setSelectedIndex(newIndex);
onChange(newFormData, props.fieldPathId.path, undefined, field_id);
}
// Sync selectedIndex when formData discriminator changes externally
// (e.g. undo/redo, loading saved state)
const currentDiscValue = formData?.[discriminatorProp];
useEffect(() => {
const idx = currentDiscValue
? enumOptions.findIndex((o) => o.discriminatorValue === currentDiscValue)
: -1;
if (idx >= 0) {
if (idx !== selectedIndex) setSelectedIndex(idx);
} else if (enumOptions.length > 0 && selectedIndex !== 0) {
// Unknown or cleared discriminator — full reset via same cleanup path
handleVariantChange("0");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDiscValue]);
// Auto-set discriminator on initial render if missing
useEffect(() => {
const discValue = enumOptions[selectedIndex]?.discriminatorValue;
if (discValue && formData?.[discriminatorProp] !== discValue) {
onChange(
{ ...formData, [discriminatorProp]: discValue },
props.fieldPathId.path,
undefined,
field_id,
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
const selector = (
<Widget
id={field_id}
name={`${name}__oneof_select`}
schema={{ type: "number", default: 0 }}
onChange={handleVariantChange}
onBlur={props.onBlur}
onFocus={props.onFocus}
disabled={props.disabled || enumOptions.length === 0}
multiple={false}
value={selectedIndex}
options={{ enumOptions }}
registry={registry}
placeholder={props.placeholder}
autocomplete={props.autocomplete}
className={cn("-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium")}
autofocus={props.autofocus}
label=""
hideLabel={true}
readonly={props.readonly}
/>
);
const DescriptionFieldTemplate = getTemplate(
"DescriptionFieldTemplate",
registry,
uiOptions,
);
const description_id = descriptionId(props.fieldPathId ?? "");
return (
<div>
<div className="flex items-center gap-2">
<Text variant="body" className="line-clamp-1">
{schema.title || name}
</Text>
<Text variant="small" className="mr-1 text-red-500">
{props.required ? "*" : null}
</Text>
{selector}
<DescriptionFieldTemplate
id={description_id}
description={schema.description || ""}
schema={schema}
registry={registry}
/>
</div>
{filteredSchema && filteredSchema.type !== "null" && (
<SchemaField
{...props}
schema={filteredSchema}
uiSchema={childUiSchema}
/>
)}
</div>
);
}

View File

@@ -6,7 +6,11 @@ import {
titleId,
} from "@rjsf/utils";
import { isAnyOfChild, isAnyOfSchema } from "../../utils/schema-utils";
import {
isAnyOfChild,
isAnyOfSchema,
isOneOfSchema,
} from "../../utils/schema-utils";
import {
cleanUpHandleId,
getHandleId,
@@ -82,12 +86,13 @@ export default function FieldTemplate(props: FieldTemplateProps) {
const shouldDisplayLabel =
displayLabel ||
(schema.type === "boolean" && !isAnyOfChild(uiSchema as any));
const shouldShowTitleSection = !isAnyOfSchema(schema) && !additional;
const isUnionSchema = isAnyOfSchema(schema) || isOneOfSchema(schema);
const shouldShowTitleSection = !isUnionSchema && !additional;
const shouldShowChildren =
schema.type === "object" ||
schema.type === "array" ||
isAnyOfSchema(schema) ||
isUnionSchema ||
!isHandleConnected;
const isAdvancedField = (schema as any).advanced === true;
@@ -95,8 +100,7 @@ export default function FieldTemplate(props: FieldTemplateProps) {
return null;
}
const marginBottom =
isPartOfAnyOf({ uiOptions }) || isAnyOfSchema(schema) ? 0 : 16;
const marginBottom = isPartOfAnyOf({ uiOptions }) || isUnionSchema ? 0 : 16;
return (
<WrapIfAdditionalTemplate

View File

@@ -7,7 +7,7 @@ import {
import { Text } from "@/components/atoms/Text/Text";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { isAnyOfSchema } from "../../utils/schema-utils";
import { isAnyOfSchema, isOneOfSchema } from "../../utils/schema-utils";
import { cn } from "@/lib/utils";
import { cleanUpHandleId, isArrayItem } from "../../helpers";
import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
@@ -18,7 +18,7 @@ export default function TitleField(props: TitleFieldProps) {
const { nodeId, showHandles } = registry.formContext;
const uiOptions = getUiOptions(uiSchema);
const isAnyOf = isAnyOfSchema(schema);
const isAnyOf = isAnyOfSchema(schema) || isOneOfSchema(schema);
const { displayType, colorClass } = getTypeDisplayInfo(schema);
const description_id = descriptionId(id);

View File

@@ -8,6 +8,14 @@ export function isAnyOfSchema(schema: RJSFSchema | undefined): boolean {
);
}
export function isOneOfSchema(schema: RJSFSchema | undefined): boolean {
return (
Array.isArray(schema?.oneOf) &&
schema!.oneOf.length > 0 &&
schema?.enum === undefined
);
}
export const isAnyOfChild = (
uiSchema: UiSchema<any, RJSFSchema, any> | undefined,
): boolean => {