# When AI Moves the Button: Build a Support Loop for Adaptive UIs

> Source: <https://dev.to/susiewang/when-ai-moves-the-button-build-a-support-loop-for-adaptive-uis-1d7>
> Published: 2026-08-03 04:12:01+00:00

An adaptive interface can look impressive right up to the moment a user asks, “Where did the export button go?”

That 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.

The 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:

This tutorial builds that control loop with React, TypeScript, Zod, and PostgreSQL.

Imagine an AI-generated account screen replaces a prominent **Cancel subscription** button with an ambiguous **Manage plan** menu.

A production-ready sequence should be:

```
Model proposes manifest
        ↓
Schema and policy validate it
        ↓
Server stores immutable revision
        ↓
Client renders revision ID ui_01J...
        ↓
User reports “I cannot find cancel”
        ↓
Report references ui_01J...
        ↓
Support replays that exact manifest
        ↓
Human freezes the scope and restores a known-good revision
```

Notice what the model does *not* control: persistence, action permissions, deployment status, or rollback.

That 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.

Do 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.

``` js
// ui-contract.ts
import { z } from "zod";

const actionIds = [
  "open_profile",
  "open_billing",
  "cancel_subscription",
  "contact_support",
] as const;

const ActionIdSchema = z.enum(actionIds);

export type UINode =
  | { type: "heading"; text: string; level: 1 | 2 }
  | { type: "text"; text: string }
  | { type: "notice"; tone: "info" | "warning"; text: string }
  | { type: "button"; label: string; actionId: z.infer<typeof ActionIdSchema> }
  | { type: "stack"; gap: "sm" | "md" | "lg"; children: UINode[] };

const UINodeSchema: z.ZodType<UINode> = z.lazy(() =>
  z.discriminatedUnion("type", [
    z.object({
      type: z.literal("heading"),
      text: z.string().min(1).max(120),
      level: z.union([z.literal(1), z.literal(2)]),
    }),
    z.object({
      type: z.literal("text"),
      text: z.string().min(1).max(500),
    }),
    z.object({
      type: z.literal("notice"),
      tone: z.enum(["info", "warning"]),
      text: z.string().min(1).max(500),
    }),
    z.object({
      type: z.literal("button"),
      label: z.string().min(1).max(80),
      actionId: ActionIdSchema,
    }),
    z.object({
      type: z.literal("stack"),
      gap: z.enum(["sm", "md", "lg"]),
      children: z.array(UINodeSchema).min(1).max(20),
    }),
  ])
);

export const UIManifestSchema = z.object({
  contractVersion: z.literal(1),
  screen: z.enum(["account_home", "billing_home"]),
  root: UINodeSchema,
});

export type UIManifest = z.infer<typeof UIManifestSchema>;
```

This 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.

Package changes should still go through the normal repository, lockfile, CI, and human review process.

A button in the manifest names an application capability; it does not define one.

``` js
// actions.ts
export const actions = {
  open_profile: () => window.location.assign("/account/profile"),
  open_billing: () => window.location.assign("/account/billing"),
  cancel_subscription: () =>
    window.location.assign("/account/billing/cancel"),
  contact_support: () => window.dispatchEvent(new Event("open-support")),
} satisfies Record<string, () => void>;
```

The renderer resolves only these registered actions:

``` python
// GeneratedUI.tsx
import type { UINode } from "./ui-contract";
import { actions } from "./actions";

export function GeneratedNode({ node }: { node: UINode }) {
  switch (node.type) {
    case "heading": {
      const Tag = node.level === 1 ? "h1" : "h2";
      return <Tag>{node.text}</Tag>;
    }
    case "text":
      return <p>{node.text}</p>;
    case "notice":
      return <aside data-tone={node.tone}>{node.text}</aside>;
    case "button":
      return (
        <button type="button" onClick={actions[node.actionId]}>
          {node.label}
        </button>
      );
    case "stack":
      return (
        <div className={`stack stack-${node.gap}`}>
          {node.children.map((child, index) => (
            <GeneratedNode key={index} node={child} />
          ))}
        </div>
      );
  }
}
```

Authorization must still be enforced by the destination API. Hiding an action or omitting it from a manifest is not access control.

A generated response should become an immutable revision. Do not overwrite a single `current_ui`

JSON column, because that destroys the evidence support needs.

```
CREATE TABLE ui_revisions (
  id text PRIMARY KEY,
  scope text NOT NULL,
  parent_id text REFERENCES ui_revisions(id),
  status text NOT NULL CHECK (
    status IN ('proposed', 'active', 'rejected', 'frozen', 'superseded')
  ),
  manifest jsonb NOT NULL,
  policy_result jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ui_scopes (
  scope text PRIMARY KEY,
  active_revision_id text NOT NULL REFERENCES ui_revisions(id),
  last_good_revision_id text NOT NULL REFERENCES ui_revisions(id),
  generation_enabled boolean NOT NULL DEFAULT true,
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE ui_feedback (
  id text PRIMARY KEY,
  revision_id text NOT NULL REFERENCES ui_revisions(id),
  category text NOT NULL CHECK (
    category IN ('cannot_find_action', 'misleading_copy', 'broken_action', 'other')
  ),
  expected_action text,
  message text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
```

Parse first, apply policy second, and only then store the proposal:

``` js
const parsed = UIManifestSchema.safeParse(modelOutput);

if (!parsed.success) {
  return { accepted: false, reason: "invalid_contract" };
}

const policy = evaluatePolicy(parsed.data);

await db.query(
  `INSERT INTO ui_revisions
     (id, scope, parent_id, status, manifest, policy_result)
   VALUES ($1, $2, $3, $4, $5, $6)`,
  [
    revisionId,
    scope,
    currentRevisionId,
    policy.autoActivate ? "active" : "proposed",
    parsed.data,
    policy,
  ]
);
```

Keep 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.

A route such as `/account`

is insufficient when two visitors may receive different layouts. Put the revision ID on the rendered screen:

```
export function AdaptiveScreen({
  revisionId,
  root,
}: {
  revisionId: string;
  root: UINode;
}) {
  return (
    <main data-ui-revision={revisionId}>
      <GeneratedNode node={root} />
      <FeedbackForm revisionId={revisionId} />
    </main>
  );
}
```

Use categories that help triage rather than asking only, “How was this experience?”

```
function FeedbackForm({ revisionId }: { revisionId: string }) {
  async function submit(formData: FormData) {
    await fetch("/api/ui-feedback", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        revisionId,
        category: formData.get("category"),
        expectedAction: formData.get("expectedAction") || null,
        message: formData.get("message"),
      }),
    });
  }

  return (
    <form action={submit}>
      <label>
        What went wrong?
        <select name="category" required>
          <option value="cannot_find_action">I cannot find an action</option>
          <option value="misleading_copy">The wording is misleading</option>
          <option value="broken_action">An action does not work</option>
          <option value="other">Something else</option>
        </select>
      </label>

      <label>
        What were you trying to do?
        <input name="expectedAction" maxLength={120} />
      </label>

      <label>
        Details
        <textarea name="message" required maxLength={2000} />
      </label>

      <button type="submit">Send feedback</button>
    </form>
  );
}
```

Validate the same fields on the server. Also verify that `revisionId`

exists; never trust a client-provided manifest.

Not every complaint deserves a global rollback. A small decision table keeps the response proportionate:

| Signal | Immediate action | Follow-up |
|---|---|---|
| One subjective copy complaint | Keep revision active | Review during normal triage |
| Repeated “cannot find action” reports on one revision | Freeze that revision | Compare against its parent |
| Registered action throws or reaches the wrong destination | Restore last-good revision | Open an application bug |
| Billing, deletion, consent, or security action becomes ambiguous | Disable generation for that scope | Require human review before reactivation |
| Invalid manifest reaches a client | Fall back immediately | Treat as a contract enforcement defect |

The 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.

A rollback should be one database transaction, not another model request:

```
BEGIN;

UPDATE ui_revisions
SET status = 'frozen'
WHERE id = $1 AND status = 'active';

UPDATE ui_scopes
SET active_revision_id = last_good_revision_id,
    generation_enabled = false,
    updated_at = now()
WHERE scope = $2
  AND active_revision_id = $1;

COMMIT;
```

If the second update affects zero rows, another revision may already be active. Return that conflict to the operator instead of silently claiming rollback succeeded.

Create an internal route such as:

```
/support/ui-revisions/:revisionId
```

It should show:

The 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`

.

This 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.

Once reports are linked to immutable revisions, AI can assist with tasks whose output remains advisory:

It should not:

The model can reduce reading and drafting work. The human still decides what the interface is allowed to mean.

A 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.

For 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.

Keep the revision reference visible and easy to copy rather than depending on undocumented widget metadata:

```
<p>
  Diagnostic code:
  <code id="ui-diagnostic">ui_revision=ui_01JABC123</code>
</p>
<button type="button" id="copy-ui-diagnostic">Copy diagnostic code</button>

<script>
  document
    .getElementById("copy-ui-diagnostic")
    .addEventListener("click", async () => {
      const value = document.getElementById("ui-diagnostic").textContent;
      await navigator.clipboard.writeText(value);
    });
</script>

<!-- Paste the Knocket script tag generated by its setup flow here. -->
```

Messages 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.

Expected result: schema validation rejects the complete proposal. Do not silently drop the button and render an incomplete screen.

Schema validity does not prove product correctness. Add scope-specific policy rules, such as requiring `cancel_subscription`

somewhere in the billing cancellation journey.

The report must continue pointing to its original immutable revision. The replay page should clearly state that the revision is no longer active.

Keep 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.

Install an error boundary outside the generated subtree. It should replace the subtree with a static known-good navigation surface and expose the revision ID.

Treat this as a release blocker. Replay mode must inject inert handlers rather than import the production action registry.

Ignore 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.

Before enabling adaptive UI for a scope, verify that:

Adaptive 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.

That is not a loss of developer value. It is a clearer description of where developer judgment is required.

*Disclosure: I work on Knocket, so treat it as one implementation example rather than a neutral recommendation.*
