{"slug": "when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis", "title": "When AI Moves the Button: Build a Support Loop for Adaptive UIs", "summary": "A developer detailed a control loop for AI-generated adaptive user interfaces, using React, TypeScript, Zod, and PostgreSQL to treat generated UI as versioned proposals. The approach validates UI manifests against a schema, stores immutable revisions, and enables support to replay exact versions, ensuring operational safety while limiting the model's control over persistence and actions.", "body_md": "An adaptive interface can look impressive right up to the moment a user asks, “Where did the export button go?”\n\nThat question creates an uncomfortable tension for developers. The model may be capable of producing a plausible layout, but plausibility is not the same as operational safety. Support cannot investigate a screen that no longer exists, and developers cannot improve a system when the only evidence is a screenshot of an ephemeral UI.\n\nThe answer is not to preserve every pixel or require human approval for every spacing change. It is to treat generated UI as a versioned proposal with three properties:\n\nThis tutorial builds that control loop with React, TypeScript, Zod, and PostgreSQL.\n\nImagine an AI-generated account screen replaces a prominent **Cancel subscription** button with an ambiguous **Manage plan** menu.\n\nA production-ready sequence should be:\n\n```\nModel proposes manifest\n        ↓\nSchema and policy validate it\n        ↓\nServer stores immutable revision\n        ↓\nClient renders revision ID ui_01J...\n        ↓\nUser reports “I cannot find cancel”\n        ↓\nReport references ui_01J...\n        ↓\nSupport replays that exact manifest\n        ↓\nHuman freezes the scope and restores a known-good revision\n```\n\nNotice what the model does *not* control: persistence, action permissions, deployment status, or rollback.\n\nThat boundary separates the demonstrated capability—generating structured interface proposals—from the hype that a model can safely “own” the interface. The difficult part remains human work: deciding which changes are harmless, which reports indicate real harm, and when novelty is no longer worth the uncertainty.\n\nDo not evaluate model-generated JSX, JavaScript, URLs, package names, or import statements. Give the model a small UI language whose actions already exist in your application.\n\n``` js\n// ui-contract.ts\nimport { z } from \"zod\";\n\nconst actionIds = [\n  \"open_profile\",\n  \"open_billing\",\n  \"cancel_subscription\",\n  \"contact_support\",\n] as const;\n\nconst ActionIdSchema = z.enum(actionIds);\n\nexport type UINode =\n  | { type: \"heading\"; text: string; level: 1 | 2 }\n  | { type: \"text\"; text: string }\n  | { type: \"notice\"; tone: \"info\" | \"warning\"; text: string }\n  | { type: \"button\"; label: string; actionId: z.infer<typeof ActionIdSchema> }\n  | { type: \"stack\"; gap: \"sm\" | \"md\" | \"lg\"; children: UINode[] };\n\nconst UINodeSchema: z.ZodType<UINode> = z.lazy(() =>\n  z.discriminatedUnion(\"type\", [\n    z.object({\n      type: z.literal(\"heading\"),\n      text: z.string().min(1).max(120),\n      level: z.union([z.literal(1), z.literal(2)]),\n    }),\n    z.object({\n      type: z.literal(\"text\"),\n      text: z.string().min(1).max(500),\n    }),\n    z.object({\n      type: z.literal(\"notice\"),\n      tone: z.enum([\"info\", \"warning\"]),\n      text: z.string().min(1).max(500),\n    }),\n    z.object({\n      type: z.literal(\"button\"),\n      label: z.string().min(1).max(80),\n      actionId: ActionIdSchema,\n    }),\n    z.object({\n      type: z.literal(\"stack\"),\n      gap: z.enum([\"sm\", \"md\", \"lg\"]),\n      children: z.array(UINodeSchema).min(1).max(20),\n    }),\n  ])\n);\n\nexport const UIManifestSchema = z.object({\n  contractVersion: z.literal(1),\n  screen: z.enum([\"account_home\", \"billing_home\"]),\n  root: UINodeSchema,\n});\n\nexport type UIManifest = z.infer<typeof UIManifestSchema>;\n```\n\nThis contract does more than prevent syntax errors. It removes an entire supply-chain failure mode: a generated layout cannot introduce a convincing but nonexistent package because dependencies are not part of the language.\n\nPackage changes should still go through the normal repository, lockfile, CI, and human review process.\n\nA button in the manifest names an application capability; it does not define one.\n\n``` js\n// actions.ts\nexport const actions = {\n  open_profile: () => window.location.assign(\"/account/profile\"),\n  open_billing: () => window.location.assign(\"/account/billing\"),\n  cancel_subscription: () =>\n    window.location.assign(\"/account/billing/cancel\"),\n  contact_support: () => window.dispatchEvent(new Event(\"open-support\")),\n} satisfies Record<string, () => void>;\n```\n\nThe renderer resolves only these registered actions:\n\n``` python\n// GeneratedUI.tsx\nimport type { UINode } from \"./ui-contract\";\nimport { actions } from \"./actions\";\n\nexport function GeneratedNode({ node }: { node: UINode }) {\n  switch (node.type) {\n    case \"heading\": {\n      const Tag = node.level === 1 ? \"h1\" : \"h2\";\n      return <Tag>{node.text}</Tag>;\n    }\n    case \"text\":\n      return <p>{node.text}</p>;\n    case \"notice\":\n      return <aside data-tone={node.tone}>{node.text}</aside>;\n    case \"button\":\n      return (\n        <button type=\"button\" onClick={actions[node.actionId]}>\n          {node.label}\n        </button>\n      );\n    case \"stack\":\n      return (\n        <div className={`stack stack-${node.gap}`}>\n          {node.children.map((child, index) => (\n            <GeneratedNode key={index} node={child} />\n          ))}\n        </div>\n      );\n  }\n}\n```\n\nAuthorization must still be enforced by the destination API. Hiding an action or omitting it from a manifest is not access control.\n\nA generated response should become an immutable revision. Do not overwrite a single `current_ui`\n\nJSON column, because that destroys the evidence support needs.\n\n```\nCREATE TABLE ui_revisions (\n  id text PRIMARY KEY,\n  scope text NOT NULL,\n  parent_id text REFERENCES ui_revisions(id),\n  status text NOT NULL CHECK (\n    status IN ('proposed', 'active', 'rejected', 'frozen', 'superseded')\n  ),\n  manifest jsonb NOT NULL,\n  policy_result jsonb NOT NULL,\n  created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE ui_scopes (\n  scope text PRIMARY KEY,\n  active_revision_id text NOT NULL REFERENCES ui_revisions(id),\n  last_good_revision_id text NOT NULL REFERENCES ui_revisions(id),\n  generation_enabled boolean NOT NULL DEFAULT true,\n  updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE ui_feedback (\n  id text PRIMARY KEY,\n  revision_id text NOT NULL REFERENCES ui_revisions(id),\n  category text NOT NULL CHECK (\n    category IN ('cannot_find_action', 'misleading_copy', 'broken_action', 'other')\n  ),\n  expected_action text,\n  message text NOT NULL,\n  created_at timestamptz NOT NULL DEFAULT now()\n);\n```\n\nParse first, apply policy second, and only then store the proposal:\n\n``` js\nconst parsed = UIManifestSchema.safeParse(modelOutput);\n\nif (!parsed.success) {\n  return { accepted: false, reason: \"invalid_contract\" };\n}\n\nconst policy = evaluatePolicy(parsed.data);\n\nawait db.query(\n  `INSERT INTO ui_revisions\n     (id, scope, parent_id, status, manifest, policy_result)\n   VALUES ($1, $2, $3, $4, $5, $6)`,\n  [\n    revisionId,\n    scope,\n    currentRevisionId,\n    policy.autoActivate ? \"active\" : \"proposed\",\n    parsed.data,\n    policy,\n  ]\n);\n```\n\nKeep the raw prompt out of this table unless you have a defined privacy and retention reason to store it. Support usually needs the resulting manifest and policy decision, not an indefinite archive of user context.\n\nA route such as `/account`\n\nis insufficient when two visitors may receive different layouts. Put the revision ID on the rendered screen:\n\n```\nexport function AdaptiveScreen({\n  revisionId,\n  root,\n}: {\n  revisionId: string;\n  root: UINode;\n}) {\n  return (\n    <main data-ui-revision={revisionId}>\n      <GeneratedNode node={root} />\n      <FeedbackForm revisionId={revisionId} />\n    </main>\n  );\n}\n```\n\nUse categories that help triage rather than asking only, “How was this experience?”\n\n```\nfunction FeedbackForm({ revisionId }: { revisionId: string }) {\n  async function submit(formData: FormData) {\n    await fetch(\"/api/ui-feedback\", {\n      method: \"POST\",\n      headers: { \"content-type\": \"application/json\" },\n      body: JSON.stringify({\n        revisionId,\n        category: formData.get(\"category\"),\n        expectedAction: formData.get(\"expectedAction\") || null,\n        message: formData.get(\"message\"),\n      }),\n    });\n  }\n\n  return (\n    <form action={submit}>\n      <label>\n        What went wrong?\n        <select name=\"category\" required>\n          <option value=\"cannot_find_action\">I cannot find an action</option>\n          <option value=\"misleading_copy\">The wording is misleading</option>\n          <option value=\"broken_action\">An action does not work</option>\n          <option value=\"other\">Something else</option>\n        </select>\n      </label>\n\n      <label>\n        What were you trying to do?\n        <input name=\"expectedAction\" maxLength={120} />\n      </label>\n\n      <label>\n        Details\n        <textarea name=\"message\" required maxLength={2000} />\n      </label>\n\n      <button type=\"submit\">Send feedback</button>\n    </form>\n  );\n}\n```\n\nValidate the same fields on the server. Also verify that `revisionId`\n\nexists; never trust a client-provided manifest.\n\nNot every complaint deserves a global rollback. A small decision table keeps the response proportionate:\n\n| Signal | Immediate action | Follow-up |\n|---|---|---|\n| One subjective copy complaint | Keep revision active | Review during normal triage |\n| Repeated “cannot find action” reports on one revision | Freeze that revision | Compare against its parent |\n| Registered action throws or reaches the wrong destination | Restore last-good revision | Open an application bug |\n| Billing, deletion, consent, or security action becomes ambiguous | Disable generation for that scope | Require human review before reactivation |\n| Invalid manifest reaches a client | Fall back immediately | Treat as a contract enforcement defect |\n\nThe important distinction is **scope**. A broken billing layout should not necessarily disable experimentation on a low-risk dashboard, but it should stop further billing mutations.\n\nA rollback should be one database transaction, not another model request:\n\n```\nBEGIN;\n\nUPDATE ui_revisions\nSET status = 'frozen'\nWHERE id = $1 AND status = 'active';\n\nUPDATE ui_scopes\nSET active_revision_id = last_good_revision_id,\n    generation_enabled = false,\n    updated_at = now()\nWHERE scope = $2\n  AND active_revision_id = $1;\n\nCOMMIT;\n```\n\nIf the second update affects zero rows, another revision may already be active. Return that conflict to the operator instead of silently claiming rollback succeeded.\n\nCreate an internal route such as:\n\n```\n/support/ui-revisions/:revisionId\n```\n\nIt should show:\n\nThe replay view must remain read-only by default. Rendering a historical manifest should not trigger analytics, navigation, billing operations, or other real actions. Replace action handlers with labels such as `Would invoke: cancel_subscription`\n\n.\n\nThis is also useful engineering practice for less-experienced developers. Instead of asking them to trust or reject “the AI,” they can inspect a concrete artifact, identify the violated invariant, and make a bounded decision. Judgment grows from examining failures, not from pretending automation removed the need to understand them.\n\nOnce reports are linked to immutable revisions, AI can assist with tasks whose output remains advisory:\n\nIt should not:\n\nThe model can reduce reading and drafting work. The human still decides what the interface is allowed to mean.\n\nA structured form is useful for aggregation, but some users need a conversation. You can add a hosted chat widget while keeping revision storage and rollback inside your application.\n\nFor example, [Knocket](https://knocket.trtc.io/) provides an embeddable live-chat widget installed with a script tag and does not require a custom chat backend. Visitors do not need an account to begin chatting.\n\nKeep the revision reference visible and easy to copy rather than depending on undocumented widget metadata:\n\n```\n<p>\n  Diagnostic code:\n  <code id=\"ui-diagnostic\">ui_revision=ui_01JABC123</code>\n</p>\n<button type=\"button\" id=\"copy-ui-diagnostic\">Copy diagnostic code</button>\n\n<script>\n  document\n    .getElementById(\"copy-ui-diagnostic\")\n    .addEventListener(\"click\", async () => {\n      const value = document.getElementById(\"ui-diagnostic\").textContent;\n      await navigator.clipboard.writeText(value);\n    });\n</script>\n\n<!-- Paste the Knocket script tag generated by its setup flow here. -->\n```\n\nMessages can be handled in its unified inbox or routed to Telegram, where a quoted reply can be delivered back to the website visitor. The durable technical record should still be the revision and structured report in your own system; chat is the conversational return path, not the rollback mechanism.\n\nExpected result: schema validation rejects the complete proposal. Do not silently drop the button and render an incomplete screen.\n\nSchema validity does not prove product correctness. Add scope-specific policy rules, such as requiring `cancel_subscription`\n\nsomewhere in the billing cancellation journey.\n\nThe report must continue pointing to its original immutable revision. The replay page should clearly state that the revision is no longer active.\n\nKeep the diagnostic code visible so the user can include it in another support channel. Do not claim the report was received until the server acknowledges it.\n\nInstall an error boundary outside the generated subtree. It should replace the subtree with a static known-good navigation surface and expose the revision ID.\n\nTreat this as a release blocker. Replay mode must inject inert handlers rather than import the production action registry.\n\nIgnore the suggestion at runtime. If the capability is genuinely needed, evaluate the package through registry verification, ownership review, lockfile changes, CI, and the same code-review process as any other dependency.\n\nBefore enabling adaptive UI for a scope, verify that:\n\nAdaptive interfaces do not remove frontend or support work. They move the work from manually arranging every screen toward defining contracts, recognizing unsafe semantics, and responding well when a proposal fails.\n\nThat is not a loss of developer value. It is a clearer description of where developer judgment is required.\n\n*Disclosure: I work on Knocket, so treat it as one implementation example rather than a neutral recommendation.*", "url": "https://wpnews.pro/news/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis", "canonical_source": "https://dev.to/susiewang/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis-1d7", "published_at": "2026-08-03 04:12:01+00:00", "updated_at": "2026-08-03 04:15:35.502884+00:00", "lang": "en", "topics": ["generative-ai", "ai-safety", "developer-tools"], "entities": ["React", "TypeScript", "Zod", "PostgreSQL"], "alternates": {"html": "https://wpnews.pro/news/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis", "markdown": "https://wpnews.pro/news/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis.md", "text": "https://wpnews.pro/news/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis.txt", "jsonld": "https://wpnews.pro/news/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis.jsonld"}}