From a8cad0d3cd3c6a5329fc07ef606df48f8ca76b24 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 7 Sep 2026 14:31:20 +0200 Subject: [PATCH] Let the placeholder flag alone decide, without requiring a textarea buildTemplatedRichContentParts asked for x-ui-widget: textarea as well as the flag, so a one-line field could not have its ${{...}} marked even when its value really is interpolated. Whether a value is interpolated is a property of the value, not of the control used to type it. A no-op for everything that exists today: every field carrying the flag gets it from LongText, whose widget defaults to textarea. It is what lets LLMDescriptor.model declare the same thing without becoming a textarea. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared/nodes/schema-driven-fields.spec.ts | 53 +++++++++++++++++++ src/app/shared/nodes/schema-driven-fields.ts | 6 ++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts index 518447a..b843336 100644 --- a/src/app/shared/nodes/schema-driven-fields.spec.ts +++ b/src/app/shared/nodes/schema-driven-fields.spec.ts @@ -5,6 +5,7 @@ import { import { parentGroupLabel } from './node-utility'; import { buildOrderedSchemaDisplay, + buildTemplatedRichContentParts, buildSchemaEditableFieldDefinitions, buildSchemaFieldViewModel, buildSchemaRetrieverContext, @@ -668,3 +669,55 @@ describe('optional groups', () => { }); }); +describe('buildTemplatedRichContentParts', () => { + /** Splitting a value on its ${{...}} placeholders so the editor can mark them. */ + const splitParts = (content: string) => + content.split(/(\$\{\{[^}]*\}\})/).filter((part) => part.length > 0) + .map((part) => ({ text: part, isDynamicInput: part.startsWith('${{') })); + + function schemaWith(field: Record) { + return { type: 'object', properties: { subject: field } }; + } + + it('splits a one-line field that says it takes placeholders', () => { + // It used to require x-ui-widget: textarea as well, so a field drawn as anything else could + // not declare that its value is interpolated - which is what LLMDescriptor.model needs. + const schema = schemaWith({ type: 'string', 'x-ui-accept-variable-as-placeholder': true }); + + const parts = buildTemplatedRichContentParts( + { subject: 'model is ${{global.modelName}}' }, 'subject', schema, splitParts); + + expect(parts).toEqual([ + { text: 'model is ', isDynamicInput: false }, + { text: '${{global.modelName}}', isDynamicInput: true } + ]); + }); + + it('still splits a textarea, which is every field that declared it until now', () => { + const schema = schemaWith({ + type: 'string', + 'x-ui-widget': 'textarea', + 'x-ui-accept-variable-as-placeholder': true + }); + + const parts = buildTemplatedRichContentParts( + { subject: 'Summarise ${{text}}' }, 'subject', schema, splitParts); + + expect(parts.some((part) => part.isDynamicInput)).toBe(true); + }); + + it('leaves a field that never claimed to take placeholders as one flat part', () => { + const schema = schemaWith({ type: 'string' }); + + const parts = buildTemplatedRichContentParts( + { subject: 'literally ${{not a placeholder}}' }, 'subject', schema, splitParts); + + expect(parts).toEqual([{ text: 'literally ${{not a placeholder}}', isDynamicInput: false }]); + }); + + it('has nothing to split when the value is empty', () => { + const schema = schemaWith({ type: 'string', 'x-ui-accept-variable-as-placeholder': true }); + + expect(buildTemplatedRichContentParts({ subject: ' ' }, 'subject', schema, splitParts)).toEqual([]); + }); +}); diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index 471042b..2385794 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -707,7 +707,11 @@ export function buildTemplatedRichContentParts( if (!content) return []; const ui = getSchemaPathUiMeta(root, path); - if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { + // The flag alone decides: whether a value is interpolated is a property of the value, not of the + // control used to type it. Requiring a textarea too meant a one-line field could not say it + // takes ${{...}} - and every field that says so today is a textarea anyway, so nothing that + // already worked changes. + if (ui.acceptVariableAsPlaceholder) { return splitParts(content); }