Elements

16 / 122 · Messages

Quote reply

Select a phrase in an answer and a toolbar offers to quote, explain, or rewrite it.

The regression happens because the converter drops parts with no text, so an empty assistant turn never reaches the thread.

Installation

npx shadcn@latest add "@assistant-ui/elements-quote-reply"

Usage

import { QuoteReply } from "@/components/elements/quote-reply";

<QuoteReply
  before="The regression happens because "
  selection="the converter drops parts with no text"
  after=", so the turn never lands."
  actions={actions}
  toolbarVisible
  onAction={handle}
/>

Props

before*stringText preceding the selection.
selection*stringThe highlighted run the actions apply to.
after*stringText following the selection.
actions*QuoteAction[]What the toolbar offers. Each action names one of the three built-in icons.
toolbarVisible*booleanWhether the floating toolbar is showing. Drive it from your own selection handler.
quotedstringOnce quoted, the text carried into the composer, shown under a rule.
onAction(key: string) => voidCalled with the key of the action that was chosen.
classNamestringExtra classes merged onto the root.

Source

quote-reply.tsx
"use client";

import { PencilLineIcon, QuoteIcon, SparklesIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { floating, mono } from "./surfaces";

export interface QuoteAction {
  key: string;
  label: string;
  icon: "quote" | "explain" | "rewrite";
}

const ICON = {
  quote: QuoteIcon,
  explain: SparklesIcon,
  rewrite: PencilLineIcon,
} as const;

export function QuoteReply({
  before,
  selection,
  after,
  actions,
  toolbarVisible,
  quoted,
  onAction,
  className,
}: {
  before: string;
  selection: string;
  after: string;
  actions: readonly QuoteAction[];
  toolbarVisible: boolean;
  quoted?: string;
  onAction?: (key: string) => void;
  className?: string;
}) {
  return (
    <div className={cn("flex w-full max-w-sm flex-col gap-2.5", className)}>
      <p className="relative text-[13.5px] leading-relaxed">
        <span className="text-foreground/70">{before}</span>
        <span className="text-foreground/95 rounded bg-blue-500/18 px-0.5 dark:bg-blue-400/25">
          {selection}
        </span>
        <span className="text-foreground/70">{after}</span>
      </p>

      <div className="flex h-9 items-start">
        {toolbarVisible && (
          <div
            className={cn(
              floating,
              "fade-in zoom-in-95 slide-in-from-top-1 animate-in flex items-center gap-0.5 rounded-full p-1 duration-200",
            )}
          >
            {actions.map((action) => {
              const Icon = ICON[action.icon];
              return (
                <button
                  key={action.key}
                  type="button"
                  onClick={() => onAction?.(action.key)}
                  className="text-foreground/60 hover:bg-foreground/[0.06] hover:text-foreground/90 flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-[background-color,color,scale] duration-150 active:scale-[0.96]"
                >
                  <Icon className="size-3.5" />
                  {action.label}
                </button>
              );
            })}
          </div>
        )}
      </div>

      {quoted && (
        <div className="fade-in slide-in-from-bottom-1 animate-in flex flex-col gap-1 duration-300">
          <span className={cn(mono, "text-foreground/30")}>replying to</span>
          <div className="border-foreground/15 text-foreground/55 border-s-2 ps-2.5 text-xs leading-relaxed">
            {quoted}
          </div>
        </div>
      )}
    </div>
  );
}