fix(ui): add anyOf/oneOf support in config form (#268)

* fix(ui): add anyOf/oneOf support in config form

- Handle literal unions as dropdowns with type preservation
- Handle primitive unions (string|number, boolean|string) as text inputs
- Unwrap single-variant optional types
- Fix enum handler to preserve types via index-based values
- Update normalizeUnion to support primitive unions in schema analysis
- Exclude allOf from union normalization (stays unsupported)

Fields like Thinking Default, Allow From, Memory now render properly
instead of showing 'unsupported schema node' errors.

* UI: fix enum placeholder collision

* Docs: update changelog for PR #268

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Sreekaran Srinath
2026-01-05 20:50:07 -08:00
committed by GitHub
parent 8880128ebf
commit 1f4d9e83ff
3 changed files with 121 additions and 12 deletions

View File

@@ -46,6 +46,7 @@
- Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.

View File

@@ -72,7 +72,7 @@ describe("config form renderer", () => {
const select = container.querySelector("select") as HTMLSelectElement | null;
expect(select).not.toBeNull();
if (!select) return;
select.value = "token";
select.value = "1";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");

View File

@@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHint, ConfigUiHints } from "../types";
export type ConfigFormProps = {
@@ -70,7 +70,7 @@ function renderNode(params: {
disabled: boolean;
showLabel?: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}) {
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
@@ -85,7 +85,95 @@ function renderNode(params: {
</div>`;
}
if (schema.anyOf || schema.oneOf || schema.allOf) {
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (nonNull.length === 1) {
return renderNode({ ...params, schema: nonNull[0] });
}
const extractLiteral = (v: JsonSchema): unknown | undefined => {
if (v.const !== undefined) return v.const;
if (v.enum && v.enum.length === 1) return v.enum[0];
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0) {
const currentIndex = literals.findIndex(
(lit) => lit === value || String(lit) === String(value),
);
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${currentIndex >= 0 ? String(currentIndex) : ""}
?disabled=${disabled}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === "" ? undefined : literals[Number(idx)]);
}}
>
<option value="">—</option>
${literals.map(
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
)}
</select>
</label>
`;
}
const primitiveTypes = ["string", "number", "integer", "boolean"];
const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type)));
if (allPrimitive) {
const typeHint = nonNull.map((v) => v.type).join(" | ");
const hasBoolean = nonNull.some((v) => v.type === "boolean");
const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer");
const isInteger = nonNull.every((v) => v.type !== "number");
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="text"
placeholder=${typeHint}
.value=${value == null ? "" : String(value)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (raw === "") {
onPatch(path, undefined);
return;
}
if (hasBoolean && (raw === "true" || raw === "false")) {
onPatch(path, raw === "true");
return;
}
if (hasNumber && /^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw);
if (Number.isFinite(num) && (!isInteger || Number.isInteger(num))) {
onPatch(path, num);
return;
}
}
onPatch(path, raw);
}}
/>
</label>
`;
}
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (schema.allOf) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
@@ -182,18 +270,26 @@ function renderNode(params: {
}
if (schema.enum) {
const enumValues = schema.enum;
const currentIndex = enumValues.findIndex(
(v) => v === value || String(v) === String(value),
);
const unsetValue = "__unset__";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${value == null ? "" : String(value)}
.value=${currentIndex >= 0 ? String(currentIndex) : unsetValue}
?disabled=${disabled}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLSelectElement).value)}
@change=${(e: Event) => {
const idx = (e.target as HTMLSelectElement).value;
onPatch(path, idx === unsetValue ? undefined : enumValues[Number(idx)]);
}}
>
${schema.enum.map(
(opt) => html`<option value=${String(opt)}>${String(opt)}</option>`,
<option value=${unsetValue}>—</option>
${enumValues.map(
(opt, i) => html`<option value=${String(i)}>${String(opt)}</option>`,
)}
</select>
</label>
@@ -327,7 +423,7 @@ function renderMapField(params: {
disabled: boolean;
reservedKeys: Set<string>;
onPatch: (path: Array<string | number>, value: unknown) => void;
}) {
}): TemplateResult {
const {
schema,
value,
@@ -517,7 +613,8 @@ function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis | null {
const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (schema.allOf) return null;
const variants = schema.anyOf ?? schema.oneOf;
if (!variants) return null;
const values: unknown[] = [];
const nonLiteral: JsonSchema[] = [];
@@ -568,11 +665,22 @@ function normalizeUnion(
if (nonLiteral.length === 1) {
const result = normalizeSchemaNode(nonLiteral[0], path);
if (result.schema) {
result.schema.nullable = true;
result.schema.nullable = nullable || result.schema.nullable;
}
return result;
}
const primitiveTypes = ["string", "number", "integer", "boolean"];
const allPrimitive = nonLiteral.every(
(v) => v.type && primitiveTypes.includes(String(v.type)),
);
if (allPrimitive && nonLiteral.length > 0 && values.length === 0) {
return {
schema: { ...schema, nullable },
unsupportedPaths: [],
};
}
return null;
}