Elements

Elements · Messages

Feedback dialog

A thumbs-down that asks why, so the signal arrives with a reason attached.

What went wrong?optional
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-feedback-dialog"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

FeedbackDialog turns a thumbs-down into a short form: pick what went wrong, add an optional note, and send. With a runtime the binary rating reaches the thread through submitFeedback; standalone you own the selection, the note, and what submitting does.

Getting started

assistant-ui's own feedback signal is binary: positive or negative, recorded on the message. ActionBarPrimitive.FeedbackNegative already submits that in one click. FeedbackDialog asks why first, so it sits in front of that signal rather than replacing it: keep the binary rating for the runtime, and carry the reasons and note yourself.

Open the panel from a trigger

A separate trigger owns the open state. FeedbackDialog itself has no open prop; it always renders its form (or its confirmation) once mounted.

components/assistant-ui/elements/message-feedback.tsx
"use client";

import { useState } from "react";
import { useAui } from "@assistant-ui/react";
import { FeedbackDialog } from "@/components/assistant-ui/elements/feedback-dialog";

const REASONS = [
  "Not factual",
  "Didn't follow instructions",
  "Too long",
  "Unsafe",
] as const;

export function MessageFeedback() {
  const aui = useAui();
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<string[]>([]);
  const [note, setNote] = useState("");
  const [sent, setSent] = useState(false);

  if (!open) {
    return (
      <button type="button" onClick={() => setOpen(true)}>
        Report an issue
      </button>
    );
  }

  return (
    <FeedbackDialog
      reasons={REASONS}
      selected={selected}
      note={note}
      sent={sent}
      onToggleReason={(reason) =>
        setSelected((current) =>
          current.includes(reason)
            ? current.filter((item) => item !== reason)
            : [...current, reason],
        )
      }
      onNoteChange={setNote}
      onSubmit={() => {
        aui.message.submitFeedback({ type: "negative" });
        setSent(true);
      }}
    />
  );
}

Register where the binary signal lands

submitFeedback reaches a FeedbackAdapter you register on the runtime. Its submit receives the message and the type only, never the reasons or the note, so those need their own transport if you want to keep them.

app/assistant.tsx
import { useLocalRuntime } from "@assistant-ui/react";

const runtime = useLocalRuntime(adapter, {
  adapters: {
    feedback: {
      submit: ({ message, type }) => {
        fetch("/api/feedback", {
          method: "POST",
          body: JSON.stringify({ messageId: message.id, type }),
        });
      },
    },
  },
});

Send selected and note from onSubmit in the same request, or a separate one; the adapter contract itself has no slot for them.

Anatomy

<div data-slot="feedback-dialog">
  <div role="status">{/* mounted in both states, so a screen reader catches "sent" the instant it flips */}</div>
  {/* not sent: a header, wrapped reason toggle pills, a note textarea, a submit button */}
  {/* sent: the form is gone; only the confirmation line remains */}
</div>

Reasons toggle independently as a multi-select (aria-pressed, any number active at once); there is no minimum. Once sent is true the form disappears entirely, so reopening it for a second report means remounting with sent back to false. The live region is present in the tree before and after the change, not created alongside it, which is what lets assistive tech announce the confirmation reliably.

Examples

Auto-reset after sending

Clearing sent, selected, and note a few seconds after a send lets the same trigger open a fresh form next time, matching the confirmation's own short lifetime:

useEffect(() => {
  if (!sent) return;
  const id = setTimeout(() => {
    setSent(false);
    setSelected([]);
    setNote("");
  }, 2600);
  return () => clearTimeout(id);
}, [sent]);

Receiving feedback on the server

An external-store runtime takes the same feedback adapter shape as useLocalRuntime:

import { useExternalStoreRuntime } from "@assistant-ui/react";

const runtime = useExternalStoreRuntime({
  // ...messages, onNew, isRunning, etc.
  adapters: {
    feedback: {
      submit: ({ message, type }) => api.rateMessage(message.id, type),
    },
  },
});

Without a registered adapter, submitFeedback still updates message.metadata.submittedFeedback locally, so ActionBarPrimitive.FeedbackNegative's pressed state stays correct even if you never wire a server side.

Restyle the panel

Both lanes take className on the root. Pills, the textarea, and the submit button read the shared field, inkButton, and mono surfaces from surfaces.tsx, so retinting those tokens retints every element that uses them.

<FeedbackDialog className="max-w-md" /* ... */ />

API reference

Message state

SelectorTypeDescription
s.message.metadata.submittedFeedback{ type: "positive" | "negative" } | undefinedThe rating already recorded for this message, if any.
aui.message.submitFeedback(feedback)(feedback: { type: "positive" | "negative" }) => voidRecords the rating on the message and calls the registered FeedbackAdapter.

FeedbackAdapter

FieldTypeDescription
submit(feedback: { message: ThreadMessage; type: "positive" | "negative" }) => voidCalled once per submitFeedback. Register it under adapters.feedback on useLocalRuntime or an external-store runtime. Carries the message and the binary type only.