Mischief

44 / Agent UI

Response Actions

The row under an answer: copy it, ask again, and rate it. Drops into the actions slot on Message.

Mischief

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.

Not rated

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/response-actions
import { ResponseActions } from "mischief-ui/response-actions"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/response-actions/response-actions.tsx
"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?: string

Usage

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.