Response actions 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. npx shadcn@latest add button dropdown-menu npm i lucide-react Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // ResponseActions.tsx ──────────────────────────────────────────────────────────────────────── 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 (
{overflow.map((item) => ( {item.label} ))}
{/* Optional, one click, and it says where the answer goes. */} {rating === "down" && (

What went wrong?

{REASONS.map((r) => ( ))}

{feedbackUse}

)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── 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("down"); return (

{ANSWER}

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 />
); }