44 / Agent UI
Response Actions
The row under an answer: copy it, ask again, and rate it. Drops into the actions slot on Message.
Not rated
"use client"
import * as React from "react"
import { Message } from "mischief-ui/message"
import {
ResponseActions,
type ResponseFeedback,
} from "mischief-ui/response-actions"
const answer =
"Rate limiting belongs in middleware so every route inherits it. Keep the counter in Redis with a sliding window, and return 429 with a Retry-After header once the window is full."
export function ResponseActionsDemo() {
const [feedback, setFeedback] = React.useState<ResponseFeedback>(null)
const [retries, setRetries] = React.useState(0)
return (
<div className="grid w-full max-w-xl gap-3">
<Message
role="assistant"
name="Mischief"
actions={
<ResponseActions
copyText={answer}
feedback={feedback}
onFeedbackChange={setFeedback}
onRetry={() => setRetries((count) => count + 1)}
/>
}
>
{answer}
</Message>
<p className="text-muted-foreground text-xs" role="status">
{feedback === null ? "Not rated" : `Rated ${feedback}`}
{retries > 0 ? ` · asked again ${retries}×` : ""}
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/response-actionsimport { ResponseActions } from "mischief-ui/response-actions"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, Clipboard, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react" import { cn } from "@/lib/utils" export type ResponseFeedback = "up" | "down" | null export type ResponseActionsProps = React.HTMLAttributes<HTMLDivElement> & { /** Copies this text. The copy control is absent without it. */ copyText?: string onRetry?: () => void retryLabel?: stringUsage
export function Answer() {
return (
<Message
role="assistant"
actions={
<ResponseActions
copyText={answer}
onRetry={regenerate}
onFeedbackChange={record}
/>
}
>
{answer}
</Message>
)
}The rating is yours to keep
The row reports a rating and remembers nothing. Left uncontrolled it holds the choice for as long as the component is mounted, which is enough for a page that will not outlive the conversation. Pass feedback and it holds nothing at all, and what is shown is whatever you say it is.
Control it whenever the rating is stored, so a failed write does not leave a thumb lit for something that was never recorded.
const [feedback, setFeedback] = useState<ResponseFeedback>(null)
<ResponseActions
copyText={answer}
feedback={feedback}
onFeedbackChange={async (next) => {
setFeedback(next)
try {
await rate(messageId, next)
} catch {
setFeedback(feedback)
}
}}
/>Choosing the current rating again clears it, and reports null. Treat that as a real answer -- someone withdrawing an opinion -- rather than as no answer.
Where it goes
Message already has an actions slot beneath its content, and this is built to sit in it. Keeping it there means the controls line up down the conversation instead of drifting with the length of each answer.
Only the controls you configure appear, so an answer that cannot usefully be retried simply has no retry button rather than a dead one. Anything else you need -- a share, a report, an overflow menu -- goes in as children and lands at the end of the row.
API
copyTextstringCopies this text. The copy control is absent without it.onRetry() => voidAdds the try-again control.retryLabelstringNames that control. Defaults to "Try again".onFeedbackChange(feedback: ResponseFeedback) => voidTurns the rating controls on.feedback, defaultFeedback"up" | "down" | nullControlled and uncontrolled rating.labelstringNames the group. Defaults to Response actions.Accessibility
The row is a labelled group of named buttons, so each one reads on its own. Ratings are toggles carrying aria-pressed, and choosing the current rating again clears it. Copying announces itself through a polite live region, and a refused clipboard is reported rather than silently doing nothing. The controls are 32px, matching the other compact toolbars in this set rather than the 44px targets used for primary actions; pass a className to enlarge them where this row is the main way to act.