diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index a266a140a..d3ff9af4b 100644 --- a/specification/v0_9/docs/a2ui_protocol.md +++ b/specification/v0_9/docs/a2ui_protocol.md @@ -148,12 +148,18 @@ This message signals the client to create a new surface and begin rendering it. ### `updateComponents` -This message provides a list of UI components to be added to or updated within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). +This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). + +**Adding or Updating Components:** +To add or update a component, provide the full component definition object. If a component with the same `id` already exists, it will be replaced. + +**Deleting Components:** +To delete a component, provide an object with the component's `id` and omit the `component` property. This removes the component from the client's memory. **Properties:** - `surfaceId` (string, required): The unique identifier for the UI surface to be updated. This is typically a name with meaning (e.g. "user_profile_card"), and it has to be unique within the context of the GenUI session. -- `components` (array, required): A list of component objects. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. +- `components` (array, required): A list of component objects (for updates) or tombstone objects (for deletions). **Example:** @@ -180,6 +186,22 @@ This message provides a list of UI components to be added to or updated within a ] } } + +{ + "updateComponents": { + "surfaceId": "user_profile_card", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["user_name"] + }, + { + "id": "user_title" + } + ] + } +} ``` ### `updateDataModel` diff --git a/specification/v0_9/eval/src/generation_flow.ts b/specification/v0_9/eval/src/generation_flow.ts index a5d9018aa..328487504 100644 --- a/specification/v0_9/eval/src/generation_flow.ts +++ b/specification/v0_9/eval/src/generation_flow.ts @@ -56,6 +56,7 @@ Standard Instructions: 13. Do NOT use a 'style' property. Use standard properties like 'align', 'justify', 'variant', etc. 14. Do NOT invent properties that are not in the schema. Check the 'properties' list for each component type. 15. Use 'checks' property for validation rules if required. +16. To delete a component, output an object with ONLY the 'id' property (omit the 'component' property). ${catalogRules ? `\nInstructions specific to this catalog:\n${catalogRules}` : ""} Schemas: diff --git a/specification/v0_9/eval/src/prompts.ts b/specification/v0_9/eval/src/prompts.ts index 6a5335041..0027f521a 100644 --- a/specification/v0_9/eval/src/prompts.ts +++ b/specification/v0_9/eval/src/prompts.ts @@ -26,6 +26,11 @@ export const prompts: TestPrompt[] = [ description: "A DeleteSurface message to remove a UI surface.", promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`, }, + { + name: "deleteComponent", + description: "An updateComponents message to delete a component.", + promptText: `Generate an 'updateComponents' message for surfaceId 'main'. This message should delete the component with ID 'loading-spinner' and update the component with ID 'status-text' to have the text "Loaded".`, + }, { name: "dogBreedGenerator", description: diff --git a/specification/v0_9/eval/src/validator.ts b/specification/v0_9/eval/src/validator.ts index e98aae7d8..c78eee535 100644 --- a/specification/v0_9/eval/src/validator.ts +++ b/specification/v0_9/eval/src/validator.ts @@ -297,8 +297,14 @@ export class Validator { } const componentType = component.component; - if (!componentType || typeof componentType !== "string") { - errors.push(`Component '${id}' is missing 'component' property.`); + + // Handle deletion (tombstone) + if (componentType === undefined || componentType === null) { + return; + } + + if (typeof componentType !== "string") { + errors.push(`Component '${id}' has invalid 'component' property.`); return; } diff --git a/specification/v0_9/json/server_to_client.json b/specification/v0_9/json/server_to_client.json index 176136fc2..170f0ee7a 100644 --- a/specification/v0_9/json/server_to_client.json +++ b/specification/v0_9/json/server_to_client.json @@ -60,7 +60,22 @@ "description": "A list containing all UI components for the surface.", "minItems": 1, "items": { - "$ref": "standard_catalog.json#/$defs/anyComponent" + "oneOf": [ + { + "$ref": "standard_catalog.json#/$defs/anyComponent" + }, + { + "type": "object", + "description": "A tombstone to remove a component. Omit the 'component' property to indicate deletion.", + "properties": { + "id": { + "$ref": "common_types.json#/$defs/ComponentId" + } + }, + "required": ["id"], + "additionalProperties": false + } + ] } } }, diff --git a/specification/v0_9/test/cases/button_checks.json b/specification/v0_9/test/cases/button_checks.json index 8e271945b..bfcf5a1b6 100644 --- a/specification/v0_9/test/cases/button_checks.json +++ b/specification/v0_9/test/cases/button_checks.json @@ -125,7 +125,7 @@ "id": "btn_primary", "component": "Button", "child": "txt_primary", - "action": { "name": "submit" }, + "action": { "event": { "name": "submit" } }, "variant": "primary" }, { "id": "txt_primary", "component": "Text", "text": "Submit" } @@ -144,7 +144,7 @@ "id": "btn_borderless", "component": "Button", "child": "txt_borderless", - "action": { "name": "cancel" }, + "action": { "event": { "name": "cancel" } }, "variant": "borderless" }, { "id": "txt_borderless", "component": "Text", "text": "Cancel" } diff --git a/specification/v0_9/test/cases/component_deletion.json b/specification/v0_9/test/cases/component_deletion.json new file mode 100644 index 000000000..d5f077d41 --- /dev/null +++ b/specification/v0_9/test/cases/component_deletion.json @@ -0,0 +1,65 @@ +{ + "schema": "server_to_client.json", + "tests": [ + { + "description": "UpdateComponents with component deletion (tombstone)", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "item_to_delete" + } + ] + } + } + }, + { + "description": "UpdateComponents mixing update and deletion", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "new_item", + "component": "Text", + "text": "Hello" + }, + { + "id": "old_item" + } + ] + } + } + }, + { + "description": "Deletion with extra properties (should be invalid)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "item_to_delete", + "text": "Should not be here" + } + ] + } + } + }, + { + "description": "Deletion missing ID (should be invalid)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + {} + ] + } + } + } + ] +} diff --git a/specification/v0_9/test/cases/contact_form_example.jsonl b/specification/v0_9/test/cases/contact_form_example.jsonl index a5a03c40e..075714b90 100644 --- a/specification/v0_9/test/cases/contact_form_example.jsonl +++ b/specification/v0_9/test/cases/contact_form_example.jsonl @@ -1,4 +1,4 @@ {"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/v0_9/standard_catalog.json"}} -{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Card","child":"form_container"},{"id":"form_container","component":"Column","children":["header_row","name_row","email_group","phone_group","pref_group","divider_1","newsletter_checkbox","submit_button"],"justify":"start","align":"stretch"},{"id":"header_row","component":"Row","children":["header_icon","header_text"],"align":"center"},{"id":"header_icon","component":"Icon","name":"mail"},{"id":"header_text","component":"Text","text":"# Contact Us","variant":"h2"},{"id":"name_row","component":"Row","children":["first_name_group","last_name_group"],"justify":"spaceBetween"},{"id":"first_name_group","component":"Column","children":["first_name_label","first_name_field"],"weight":1},{"id":"first_name_label","component":"Text","text":"First Name","variant":"caption"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_group","component":"Column","children":["last_name_label","last_name_field"],"weight":1},{"id":"last_name_label","component":"Text","text":"Last Name","variant":"caption"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_group","component":"Column","children":["email_label","email_field"]},{"id":"email_label","component":"Text","text":"Email Address","variant":"caption"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"required","args":[{"path":"/contact/email"}],"message":"Email is required."},{"call":"email","args":[{"path":"/contact/email"}],"message":"Please enter a valid email address."}]},{"id":"phone_group","component":"Column","children":["phone_label","phone_field"]},{"id":"phone_label","component":"Text","text":"Phone Number","variant":"caption"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText","checks":[{"call":"regex","args":[{"path":"/contact/phone"},"^\\d{10}$\\"],"message":"Phone number must be 10 digits."}]},{"id":"pref_group","component":"Column","children":["pref_label","pref_picker"]},{"id":"pref_label","component":"Text","text":"Preferred Contact Method","variant":"caption"},{"id":"pref_picker","component":"ChoicePicker","variant":"mutuallyExclusive","options":[{"label":"Email","value":"email"},{"label":"Phone","value":"phone"},{"label":"SMS","value":"sms"}],"value":{"path":"/contact/preference"}},{"id":"divider_1","component":"Divider","axis":"horizontal"},{"id":"newsletter_checkbox","component":"CheckBox","label":"Subscribe to our newsletter","value":{"path":"/contact/subscribe"}},{"id":"submit_button_label","component":"Text","text":"Send Message"},{"id":"submit_button","component":"Button","child":"submit_button_label","variant":"primary","action":{"name":"submitContactForm","context":{"formId":"contact_form_1","clientTime":{"call":"now","returnType":"string"},"isNewsletterSubscribed":{"path":"/contact/subscribe"}}}}}]}} +{"updateComponents": {"surfaceId": "contact_form_1", "components": [{"id": "root", "component": "Card", "child": "form_container"}, {"id": "form_container", "component": "Column", "children": ["header_row", "name_row", "email_group", "phone_group", "pref_group", "divider_1", "newsletter_checkbox", "submit_button"], "justify": "start", "align": "stretch"}, {"id": "header_row", "component": "Row", "children": ["header_icon", "header_text"], "align": "center"}, {"id": "header_icon", "component": "Icon", "name": "mail"}, {"id": "header_text", "component": "Text", "text": "# Contact Us", "variant": "h2"}, {"id": "name_row", "component": "Row", "children": ["first_name_group", "last_name_group"], "justify": "spaceBetween"}, {"id": "first_name_group", "component": "Column", "children": ["first_name_label", "first_name_field"], "weight": 1}, {"id": "first_name_label", "component": "Text", "text": "First Name", "variant": "caption"}, {"id": "first_name_field", "component": "TextField", "label": "First Name", "value": {"path": "/contact/firstName"}, "variant": "shortText"}, {"id": "last_name_group", "component": "Column", "children": ["last_name_label", "last_name_field"], "weight": 1}, {"id": "last_name_label", "component": "Text", "text": "Last Name", "variant": "caption"}, {"id": "last_name_field", "component": "TextField", "label": "Last Name", "value": {"path": "/contact/lastName"}, "variant": "shortText"}, {"id": "email_group", "component": "Column", "children": ["email_label", "email_field"]}, {"id": "email_label", "component": "Text", "text": "Email Address", "variant": "caption"}, {"id": "email_field", "component": "TextField", "label": "Email", "value": {"path": "/contact/email"}, "variant": "shortText", "checks": [{"call": "required", "args": [{"path": "/contact/email"}], "message": "Email is required."}, {"call": "email", "args": [{"path": "/contact/email"}], "message": "Please enter a valid email address."}]}, {"id": "phone_group", "component": "Column", "children": ["phone_label", "phone_field"]}, {"id": "phone_label", "component": "Text", "text": "Phone Number", "variant": "caption"}, {"id": "phone_field", "component": "TextField", "label": "Phone", "value": {"path": "/contact/phone"}, "variant": "shortText", "checks": [{"call": "regex", "args": [{"path": "/contact/phone"}, "^\\d{10}$"], "message": "Phone number must be 10 digits."}]}, {"id": "pref_group", "component": "Column", "children": ["pref_label", "pref_picker"]}, {"id": "pref_label", "component": "Text", "text": "Preferred Contact Method", "variant": "caption"}, {"id": "pref_picker", "component": "ChoicePicker", "variant": "mutuallyExclusive", "options": [{"label": "Email", "value": "email"}, {"label": "Phone", "value": "phone"}, {"label": "SMS", "value": "sms"}], "value": {"path": "/contact/preference"}}, {"id": "divider_1", "component": "Divider", "axis": "horizontal"}, {"id": "newsletter_checkbox", "component": "CheckBox", "label": "Subscribe to our newsletter", "value": {"path": "/contact/subscribe"}}, {"id": "submit_button_label", "component": "Text", "text": "Send Message"}, {"id": "submit_button", "component": "Button", "child": "submit_button_label", "variant": "primary", "action": {"event": {"name": "submitContactForm", "context": {"formId": "contact_form_1", "clientTime": {"call": "now", "returnType": "string"}, "isNewsletterSubscribed": {"path": "/contact/subscribe"}}}}}]}} {"updateDataModel":{"surfaceId":"contact_form_1","path":"/contact","value":{"firstName":"John","lastName":"Doe","email":"john.doe@example.com","phone":"1234567890","preference":["email"],"subscribe":true}}} {"deleteSurface":{"surfaceId":"contact_form_1"}} \ No newline at end of file