Skip to content
KONIGI Patterns

AI Assistants / Messages and responses / Response actions

‹ 4 of 6 ›

Response actions

People need easy access to actions on an answer without crowding the conversation.

Updated September 12, 2026

Problem

People need to copy or act on an answer, but a row of controls beneath every message can make the conversation harder to read.

Solution

Place the most useful actions in a compact row beneath the answer. Give common tasks, such as copying, priority over controls used less often. Put additional actions in a menu.

Keep controls available to keyboard and touch users. If older messages reveal actions on hover, reveal them on focus too and provide a touch-accessible option. Give icons clear names and tooltips.

For ratings, offer an optional way to explain the problem. Say how feedback will be used without requiring a form just to submit a rating.

Use when

People need to copy, revise, share, or give feedback on an assistant’s answers.

Don’t use when

The response is ephemeral or inline, like a ghost-text completion or a one-line inline rewrite. There the accept and reject controls are the action row, and adding copy or rating alongside them confuses the primary decision.

Trade-offs

Visible controls add clutter, while hidden controls can be missed. Icons save space but may be ambiguous. Feedback controls create an expectation that the feedback has a purpose, which the product should explain.

Checklist

  • Is copy the easiest thing in the row to hit?
  • Does the row reach keyboard and touch users, or only a pointer?
  • What’s the order, and is it ranked by viewer behaviour or by internal need?
  • Does a thumbs-down collect anything beyond one bit?
  • Is it clear what happens to a rating after it is clicked?
  • Does copy take the markdown, the rendered text, or the raw response?
  • How many items does the row hold, and what’s the rule for adding the next one?
  • Do the actions appear before the response has finished streaming?
  • Can a single turn be shared or linked without sharing the whole conversation?
  • Are the icons legible without their tooltips?

Compare

ChatGPT runs a compact row under each answer with copy, a thumbs pair, read-aloud and regenerate, and reveals it on the turn rather than holding it always-on down the page. Claude keeps the row narrower and moves the heavier actions onto the generated document when one exists, so the transcript row stays about the text and the artifact carries its own controls. Gemini treats sharing as a first-class action rather than an overflow item, exporting a response into a Doc. The row becomes an exit ramp into another product rather than a clipboard. Perplexity anchors its row on rewriting and re-sourcing instead of rating, which fits a product where the next move is usually another search rather than a judgement about the last answer.

Message turn contains the answer these controls act on. Regenerate requests another attempt. Artifact panel gives documents their own controls.

Code block actions applies to a single code block. Streaming response affects when an answer is complete enough for an action.

Response actions anatomy The control row under an assistant turn: copy first because it is used most, then a thumbs pair, then regenerate, with the remainder behind an overflow menu. A follow-up question after a thumbs-down collects a category instead of one bit. Ranked by what people do, not by what the team wants copy + − retry … What went wrong? wrong too long off topic used to tune retrieval, not to train the model 1 2 3 4 1 COPY COMES FIRST Most interaction with this row is taking the answer somewhere else. 2 THE OVERFLOW Every team wants a button here. Four becomes seven without a rule. 3 GRANULAR FEEDBACK A bare thumbs-down collects one bit and cannot tell wrong from long from rude. 4 SAY WHERE IT GOES A control that quietly feeds a training pipeline is asking for unexplained trust. A row revealed only on hover reaches neither touch nor keyboard, and those users complain least.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

The control row ranked by what people actually do. Copy comes first because it is used most, then the thumbs pair, then retry, with the rest behind an overflow. A thumbs-down that collects a category is worth more than one that collects a bit.

shadcn
npx shadcn@latest add button dropdown-menu
npm
lucide-react
Tokens
--card--card-foreground--muted--muted-foreground--border--chart-1

The break clause is at 12.1: six months' written notice, exercisable at the end of year two, conditional on the rent being paid up to date and vacant possession on the break date.

What went wrong?

used to tune retrieval, not to train the model

ResponseActions.tsxFour in the row and the rest in an overflow. A thumbs-down asks for a reason from a closed set, and the text saying where a rating goes is a required prop.

import { Copy, RotateCcw, ThumbsDown, ThumbsUp, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export type Rating = "up" | "down" | null;

/** A thumbs-down is one bit. The follow-up turns it into a category, and the
 *  set is closed so the numbers add up across turns. */
export type Reason = "wrong" | "too long" | "off topic";
export const REASONS: Reason[] = ["wrong", "too long", "off topic"];

export type OverflowItem = { label: string; onSelect: () => void };

type Props = {
  /** What copy takes. The caller decides markdown, rendered text or raw. */
  copyText: string;
  rating: Rating;
  onRate: (rating: Rating) => void;
  onReason: (reason: Reason) => void;
  onRetry: () => void;
  /** Everything past the top four. Read aloud, share, report: they exist,
   *  they are one click further away. */
  overflow: OverflowItem[];
  /** Where a rating goes once clicked. Required, because a control that
   *  quietly feeds a pipeline is asking for trust it has not explained. */
  feedbackUse: string;
  /** The newest turn keeps its row; older turns reveal it on hover or focus,
   *  so the keyboard reaches it the same way the pointer does. */
  pinned?: boolean;
  onCopy?: () => void;
};

export function ResponseActions({
  copyText, rating, onRate, onReason, onRetry, overflow, feedbackUse, pinned = false, onCopy,
}: Props) {
  const copy = () => {
    navigator.clipboard?.writeText(copyText);
    onCopy?.();
  };
  const reveal = pinned ? "" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100";

  return (
    <div className={`text-[11px] ${reveal}`}>
      <div className="flex items-center gap-2">
        <Button variant="outline" size="sm" className="h-7 gap-1.5 border-chart-1 px-2.5 text-[11px] text-chart-1 hover:text-chart-1" onClick={copy}>
          <Copy className="size-3" /> copy
        </Button>
        <Button variant="outline" size="sm" className={`h-7 w-8 px-0 ${rating === "up" ? "text-card-foreground" : "text-muted-foreground"}`} aria-label="helpful" aria-pressed={rating === "up"} onClick={() => onRate(rating === "up" ? null : "up")}>
          <ThumbsUp className="size-3" />
        </Button>
        <Button variant="outline" size="sm" className={`h-7 w-8 px-0 ${rating === "down" ? "text-card-foreground" : "text-muted-foreground"}`} aria-label="not helpful" aria-pressed={rating === "down"} onClick={() => onRate(rating === "down" ? null : "down")}>
          <ThumbsDown className="size-3" />
        </Button>
        <Button variant="outline" size="sm" className="h-7 gap-1.5 px-2.5 text-[11px] text-card-foreground" onClick={onRetry}>
          <RotateCcw className="size-3" /> retry
        </Button>
        <DropdownMenu modal={false}>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" size="sm" className="h-7 w-8 px-0 text-muted-foreground" aria-label="more">
              <MoreHorizontal className="size-3" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            {overflow.map((item) => (
              <DropdownMenuItem key={item.label} onSelect={item.onSelect}>{item.label}</DropdownMenuItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      {/* Optional, one click, and it says where the answer goes. */}
      {rating === "down" && (
        <div className="ml-24 mt-4 w-[340px] max-w-full rounded-lg border bg-muted p-3">
          <p className="text-sm text-card-foreground">What went wrong?</p>
          <div className="mt-2 flex flex-wrap gap-2">
            {REASONS.map((r) => (
              <Button key={r} variant="outline" size="sm" className="h-6 rounded-full px-2.5 text-[11px]" onClick={() => onReason(r)}>{r}</Button>
            ))}
          </div>
          <p className="mt-3 text-muted-foreground">{feedbackUse}</p>
        </div>
      )}
    </div>
  );
}

demo.tsxHow it is called: the newest turn, pinned, opened on a thumbs-down with the follow-up showing.

import { useState } from "react";
import { ResponseActions, type Rating } from "./ResponseActions";

const ANSWER =
  "The break clause is at 12.1: six months' written notice, exercisable at the end of year two, conditional on the rent being paid up to date and vacant possession on the break date.";

/**
 * The newest turn, so the row is pinned. It opens on a thumbs-down with the
 * follow-up showing; picking a reason closes it, and thumbs-up clears it.
 */
export default function Demo() {
  const [rating, setRating] = useState<Rating>("down");
  return (
    <div className="group rounded-lg border bg-card p-4">
      <p className="text-sm leading-relaxed text-card-foreground">{ANSWER}</p>
      <div className="mt-4">
        <ResponseActions
          copyText={ANSWER}
          rating={rating}
          onRate={setRating}
          onReason={() => setRating(null)}
          onRetry={() => {}}
          overflow={[
            { label: "read aloud", onSelect: () => {} },
            { label: "share this turn", onSelect: () => {} },
            { label: "report", onSelect: () => {} },
          ]}
          feedbackUse="used to tune retrieval, not to train the model"
          pinned
        />
      </div>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.