Skip to content

The row under a reply: copy draws a tick, regenerate turns, thumbs fill.

AI conversationmotion@base-ui/react

01Preview

The deploy on Tuesday 14:02 shipped api@4.18.0, which moved session lookups from Redis to Postgres.

Why did checkout get slower after that?

p95 on /v1/checkout went from 180 ms to 410 ms right after that deploy. Two things line up:

1. Session reads now hit Postgres on every request instead of the cache.

2. The sessions table has no index on token_hash, so each read is a sequential scan.

Adding the index and restoring the cache layer should bring p95 back under 200 ms.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react

03Usage

import {
  MessageActions,
  MessageActionCopy,
  MessageActionRegenerate,
  MessageActionFeedback,
  MessageActionShare,
  MessageActionsMore,
  MessageActionsMenuItem,
} from "@/components/ui/message-actions";

<div data-message>
  <Markdown>{reply.markdown}</Markdown>
  <MessageActions reveal={isLatest ? "always" : "hover"}>
    <MessageActionCopy value={reply.markdown} />
    <MessageActionRegenerate busy={isStreaming} onRegenerate={() => regenerate(reply.id)} />
    <MessageActionFeedback
      reasons={["Not accurate", "Missed the question", "Too long"]}
      onValueChange={(v) => rate(reply.id, v)}
      onDetailsSubmit={(d) => sendFeedback(reply.id, d)}
    />
    <MessageActionShare onClick={() => share(reply.id)} />
    <MessageActionsMore>
      <MessageActionsMenuItem onClick={readAloud}>Read aloud</MessageActionsMenuItem>
    </MessageActionsMore>
  </MessageActions>
</div>

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { Popover } from "@base-ui/react/popover";
import { Toolbar } from "@base-ui/react/toolbar";
import { Tooltip } from "@base-ui/react/tooltip";
import {
  AnimatePresence,
  animate,
  motion,
  useAnimate,
  useMotionValue,
  useReducedMotion,
  type AnimationPlaybackControls,
} from "motion/react";
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
import { useCopy } from "@/components/ui/copy-button";
import { cn } from "@/lib/cn";
import { Check, Copy, MoreH, Refresh, Share, X } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

/* -------------------------------------------------------------------------------------------------
 * Root
 * -----------------------------------------------------------------------------------------------*/

type Ctx = { announce: (message: string) => void };
const ActionsContext = createContext<Ctx>({ announce: () => {} });

export type MessageActionsProps = Omit<Toolbar.Root.Props, "className" | "orientation"> & {
  className?: string;
  /**
   * `hover` keeps the row invisible until the message (any ancestor with `data-message`) or the
   * row itself is hovered, or a control inside is focused from the keyboard. Touch screens always
   * show it. `always` is for the latest reply.
   */
  reveal?: "hover" | "always";
};

/**
 * The row under a reply. A toolbar: one Tab stop, arrow keys move between actions, Home and End
 * jump to the ends. Its height is always reserved, so revealing it never moves the thread.
 */
export function MessageActions({ reveal = "always", className, children, "aria-label": ariaLabel = "Message actions", ...rest }: MessageActionsProps) {
  const [message, setMessage] = useState("");
  const frame = useRef(0);
  useEffect(() => () => cancelAnimationFrame(frame.current), []);
  // Clearing first lets the same sentence be announced twice in a row.
  const announce = useCallback((next: string) => {
    setMessage("");
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => setMessage(next));
  }, []);

  return (
    <ActionsContext value={{ announce }}>
      {/* One warm-up for every tooltip in the row: the first waits, its neighbors open on contact. */}
      <Tooltip.Provider delay={500} timeout={400}>
        <Toolbar.Root
          aria-label={ariaLabel}
          data-mode={reveal}
          className={cn(
            "relative flex min-h-7 items-center gap-0.5 pointer-coarse:min-h-9",
            // --reveal drives every action's opacity. Hidden only on fine pointers; the :where keeps the
            // hiding rule weaker than every rule that reveals.
            "pointer-fine:[&:where([data-mode=hover])]:[--reveal:0]",
            "[[data-message]:hover_&]:[--reveal:1] data-[mode=hover]:hover:[--reveal:1]",
            "data-[mode=hover]:has-[:focus-visible]:[--reveal:1] data-[mode=hover]:has-[[data-popup-open]]:[--reveal:1]",
            className,
          )}
          {...rest}
        >
          {children}
          <span role="status" aria-live="polite" className="sr-only">
            {message}
          </span>
        </Toolbar.Root>
      </Tooltip.Provider>
    </ActionsContext>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Shared pieces: the button look and its tooltip
 * -----------------------------------------------------------------------------------------------*/

const buttonClass = cn(
  "relative inline-flex size-7 shrink-0 select-none items-center justify-center rounded-md text-fg-3 outline-none",
  "touch-manipulation [-webkit-tap-highlight-color:transparent]",
  "opacity-[var(--reveal,1)] data-keep:opacity-100",
  "transition-[opacity,background-color,color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75",
  "hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg aria-pressed:text-fg",
  "data-[state=failed]:text-danger data-[state=failed]:hover:text-danger data-busy:cursor-progress data-busy:text-fg-2 data-busy:hover:text-fg-2",
  "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
  "data-disabled:cursor-not-allowed data-disabled:text-fg-4 data-disabled:hover:bg-transparent",
  // Draws at 28px. On touch it grows to 36px and the hit area reaches 44px.
  "pointer-coarse:size-9 before:absolute before:-inset-1 before:content-[''] pointer-fine:before:hidden",
  "motion-reduce:active:scale-100",
);

function Tip({ label, closeOnClick = true, children }: { label: React.ReactNode; closeOnClick?: boolean; children: React.ReactElement<Record<string, unknown>> }) {
  return (
    <Tooltip.Root>
      <Tooltip.Trigger render={children} closeOnClick={closeOnClick} />
      <Tooltip.Portal>
        <Tooltip.Positioner side="top" sideOffset={6} collisionPadding={8} className="z-(--z-tooltip)">
          <Tooltip.Popup
            className={cn(
              "origin-(--transform-origin) whitespace-nowrap rounded-md border border-line-2 bg-raised px-1.5 py-[3px] text-[11.5px] leading-4 text-fg shadow-pop",
              "transition-[opacity,scale,translate] duration-150 ease-out-expo",
              "data-starting-style:scale-96 data-starting-style:translate-y-0.5 data-starting-style:opacity-0",
              "data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none",
              "motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
            )}
          >
            {label}
          </Tooltip.Popup>
        </Tooltip.Positioner>
      </Tooltip.Portal>
    </Tooltip.Root>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Generic action
 * -----------------------------------------------------------------------------------------------*/

export type MessageActionProps = Omit<Toolbar.Button.Props, "className" | "children"> & {
  /** The accessible name, and the tooltip unless `tooltip` says otherwise. Say what it does. */
  label: string;
  tooltip?: React.ReactNode;
  /** Keep this action visible while the rest of a hover row is hidden. */
  keep?: boolean;
  className?: string;
  /** The icon. */
  children: React.ReactNode;
};

/** Any icon action: read aloud, edit, bookmark. Share is this with a Share icon. */
export function MessageAction({ label, tooltip, keep, className, children, ...rest }: MessageActionProps) {
  return (
    <Tip label={tooltip ?? label}>
      <Toolbar.Button aria-label={label} data-keep={keep ? "" : undefined} className={cn(buttonClass, className)} {...rest}>
        {children}
      </Toolbar.Button>
    </Tip>
  );
}

export type MessageActionShareProps = Omit<MessageActionProps, "label" | "children"> & { label?: string };

export function MessageActionShare({ label = "Share", ...rest }: MessageActionShareProps) {
  return (
    <MessageAction label={label} {...rest}>
      <Share />
    </MessageAction>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Copy
 * -----------------------------------------------------------------------------------------------*/

export type MessageActionCopyProps = Omit<Toolbar.Button.Props, "className" | "children" | "value" | "onCopy"> & {
  /** The reply's markdown source (not the rendered text), or a function that returns it. */
  value: string | (() => string | Promise<string>);
  label?: string;
  copiedLabel?: string;
  failedLabel?: string;
  onCopied?: (text: string) => void;
  onError?: (error: unknown) => void;
  className?: string;
};

export function MessageActionCopy({
  value,
  label = "Copy",
  copiedLabel = "Copied",
  failedLabel = "Couldn’t copy",
  onCopied,
  onError,
  className,
  onClick,
  ...rest
}: MessageActionCopyProps) {
  const { announce } = useContext(ActionsContext);
  const reduce = !!useReducedMotion();
  const { state, copy } = useCopy({
    timeout: 1600,
    onCopied: (text) => {
      announce(copiedLabel);
      onCopied?.(text);
    },
    onError: (error) => {
      announce(`${failedLabel}. Select the text and copy it instead.`);
      onError?.(error);
    },
  });
  const text = state === "copied" ? copiedLabel : state === "failed" ? failedLabel : label;

  return (
    <Tip label={text} closeOnClick={false}>
      <Toolbar.Button
        aria-label={label}
        data-state={state}
        data-keep={state !== "idle" ? "" : undefined}
        onClick={(e) => {
          onClick?.(e);
          if (!e.defaultPrevented) copy(value);
        }}
        className={cn(buttonClass, className)}
        {...rest}
      >
        <SwapIcon id={state} reduce={reduce}>
          {state === "copied" ? <DrawnCheck reduce={reduce} /> : state === "failed" ? <X /> : <Copy />}
        </SwapIcon>
      </Toolbar.Button>
    </Tip>
  );
}

function SwapIcon({ id, reduce, children }: { id: string; reduce: boolean; children: React.ReactNode }) {
  return (
    <span className="relative grid size-4 place-items-center">
      <AnimatePresence initial={false}>
        <motion.span
          key={id}
          className="absolute inset-0 grid place-items-center"
          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(3px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(3px)" }}
          transition={reduce ? { duration: 0.12 } : spring.pop}
        >
          {children}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function DrawnCheck({ reduce }: { reduce: boolean }) {
  return (
    <svg width={16} height={16} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      <motion.path
        d="M3.5 8.5 6.5 11.5 12.5 4.5"
        initial={reduce ? false : { pathLength: 0 }}
        animate={{ pathLength: 1 }}
        transition={{ duration: 0.3, ease: ease.out, delay: 0.04 }}
      />
    </svg>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Regenerate
 * -----------------------------------------------------------------------------------------------*/

export type MessageActionRegenerateProps = Omit<Toolbar.Button.Props, "className" | "children"> & {
  /** Return a promise and the arrow keeps turning until it settles. */
  onRegenerate?: () => void | Promise<unknown>;
  /** Controlled busy state, for when the new reply streams in elsewhere. */
  busy?: boolean;
  label?: string;
  busyLabel?: string;
  className?: string;
};

export function MessageActionRegenerate({
  onRegenerate,
  busy: busyProp,
  label = "Regenerate",
  busyLabel = "Regenerating…",
  className,
  onClick,
  ...rest
}: MessageActionRegenerateProps) {
  const { announce } = useContext(ActionsContext);
  const reduce = !!useReducedMotion();
  const [pending, setPending] = useState(false);
  const busy = busyProp ?? pending;
  const rotate = useMotionValue(0);
  const turn = useRef<AnimationPlaybackControls | null>(null);

  // While busy the arrow turns steadily. When the work ends it doesn't snap back: it eases
  // forward to the next full turn, so it always comes to rest pointing the way it started.
  useEffect(() => {
    if (reduce) return;
    turn.current?.stop();
    const from = rotate.get();
    if (busy) {
      turn.current = animate(rotate, [from, from + 360], { duration: 0.9, ease: "linear", repeat: Infinity });
    } else if (from % 360 !== 0) {
      const rest = Math.ceil(from / 360) * 360 + (from % 360 > 270 ? 360 : 0);
      turn.current = animate(rotate, rest, { duration: 0.5, ease: ease.out });
    }
    return () => turn.current?.stop();
  }, [busy, reduce, rotate]);

  return (
    <Tip label={busy ? busyLabel : label} closeOnClick={false}>
      <Toolbar.Button
        aria-label={busy ? busyLabel : label}
        aria-disabled={busy || undefined}
        data-busy={busy ? "" : undefined}
        data-keep={busy ? "" : undefined}
        onClick={(e) => {
          onClick?.(e);
          if (e.defaultPrevented || busy) return;
          if (!reduce) {
            // Starts turning on the press itself; a busy state picks the turn up from here.
            turn.current?.stop();
            turn.current = animate(rotate, rotate.get() + 360, { duration: 0.6, ease: ease.inOut });
          }
          announce(busyLabel);
          const result = onRegenerate?.();
          if (result && typeof (result as Promise<unknown>).then === "function") {
            setPending(true);
            (result as Promise<unknown>).finally(() => setPending(false));
          }
        }}
        className={cn(buttonClass, className)}
        {...rest}
      >
        <motion.span style={{ rotate }} className="grid size-4 place-items-center">
          <Refresh />
        </motion.span>
      </Toolbar.Button>
    </Tip>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Feedback: thumbs up and down, with an optional "what went wrong" follow-up
 * -----------------------------------------------------------------------------------------------*/

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

export type MessageFeedbackDetails = { reasons: string[]; comment: string };

export type MessageActionFeedbackProps = {
  value?: MessageFeedback;
  defaultValue?: MessageFeedback;
  onValueChange?: (value: MessageFeedback) => void;
  /** Offer these after a thumbs down. Leave empty to skip the follow-up. */
  reasons?: string[];
  /** Called when the follow-up is sent. */
  onDetailsSubmit?: (details: MessageFeedbackDetails) => void;
  upLabel?: string;
  downLabel?: string;
  disabled?: boolean;
};

const UP = "M5 7.25v6H3a.75.75 0 0 1-.75-.75V8A.75.75 0 0 1 3 7.25zm0 0 2.5-4.5a1.4 1.4 0 0 1 1.9 1.3l-.4 2.2h3.3a1.25 1.25 0 0 1 1.2 1.6l-1.3 4.3a1.25 1.25 0 0 1-1.2.85H5";
const DOWN = "M11 8.75v-6h2a.75.75 0 0 1 .75.75V8a.75.75 0 0 1-.75.75zm0 0-2.5 4.5a1.4 1.4 0 0 1-1.9-1.3l.4-2.2H3.7a1.25 1.25 0 0 1-1.2-1.6l1.3-4.3a1.25 1.25 0 0 1 1.2-.85H11";

export function MessageActionFeedback({
  value,
  defaultValue = null,
  onValueChange,
  reasons = [],
  onDetailsSubmit,
  upLabel = "Good response",
  downLabel = "Bad response",
  disabled,
}: MessageActionFeedbackProps) {
  const { announce } = useContext(ActionsContext);
  const [current, setCurrent] = useControllableState<MessageFeedback>({ value, defaultValue, onChange: onValueChange });
  const [open, setOpen] = useState(false);
  const downRef = useRef<HTMLButtonElement>(null);

  const choose = (next: "up" | "down") => {
    const resolved = current === next ? null : next;
    setCurrent(resolved);
    announce(resolved === "up" ? `Marked as ${upLabel.toLowerCase()}` : resolved === "down" ? `Marked as ${downLabel.toLowerCase()}` : "Feedback removed");
    setOpen(resolved === "down" && reasons.length > 0);
  };

  return (
    <>
      <Thumb dir="up" label={upLabel} pressed={current === "up"} disabled={disabled} onPress={() => choose("up")} />
      <Thumb ref={downRef} dir="down" label={downLabel} pressed={current === "down"} disabled={disabled} onPress={() => choose("down")} popupOpen={open} />
      {reasons.length > 0 && (
        <FeedbackDetails
          open={open}
          onOpenChange={setOpen}
          anchor={downRef}
          reasons={reasons}
          onSubmit={(details) => {
            setOpen(false);
            announce("Feedback sent");
            onDetailsSubmit?.(details);
          }}
        />
      )}
    </>
  );
}

function Thumb({
  dir,
  label,
  pressed,
  disabled,
  popupOpen,
  onPress,
  ref,
}: {
  dir: "up" | "down";
  label: string;
  pressed: boolean;
  disabled?: boolean;
  popupOpen?: boolean;
  onPress: () => void;
  ref?: React.Ref<HTMLButtonElement>;
}) {
  const reduce = !!useReducedMotion();
  const [scope, run] = useAnimate<HTMLSpanElement>();

  return (
    <Tip label={label}>
      <Toolbar.Button
        ref={ref}
        aria-label={label}
        aria-pressed={pressed}
        disabled={disabled}
        data-keep={pressed ? "" : undefined}
        data-popup-open={popupOpen ? "" : undefined}
        onClick={() => {
          // The acknowledgement: a small nod in the thumb's own direction as it fills.
          if (!pressed && !reduce)
            run(scope.current, { rotate: [0, dir === "up" ? -14 : 14, 0], scale: [1, 0.86, 1], y: [0, dir === "up" ? -1.5 : 1.5, 0] }, { duration: 0.42, ease: ease.out });
          onPress();
        }}
        className={buttonClass}
      >
        <span ref={scope} className="grid size-4 place-items-center">
          <svg width={16} height={16} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
            <motion.path
              d={dir === "up" ? UP : DOWN}
              fill="currentColor"
              initial={false}
              animate={{ fillOpacity: pressed ? 1 : 0 }}
              transition={{ duration: pressed ? 0.2 : 0.14, ease: ease.out, delay: pressed && !reduce ? 0.06 : 0 }}
            />
          </svg>
        </span>
      </Toolbar.Button>
    </Tip>
  );
}

function FeedbackDetails({
  open,
  onOpenChange,
  anchor,
  reasons,
  onSubmit,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  anchor: React.RefObject<HTMLButtonElement | null>;
  reasons: string[];
  onSubmit: (details: MessageFeedbackDetails) => void;
}) {
  const [picked, setPicked] = useState<string[]>([]);
  const [comment, setComment] = useState("");
  const id = useId();
  const empty = picked.length === 0 && comment.trim() === "";

  return (
    <Popover.Root
      open={open}
      onOpenChange={(next) => onOpenChange(next)}
      onOpenChangeComplete={(next) => {
        if (!next) {
          setPicked([]);
          setComment("");
        }
      }}
    >
      <Popover.Portal>
        <Popover.Positioner anchor={anchor} side="bottom" align="start" sideOffset={8} collisionPadding={12} className="z-(--z-popover)">
          <Popover.Popup
            finalFocus={anchor}
            className={cn(
              "w-[min(20rem,calc(100vw-24px))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3 text-fg shadow-pop outline-none",
              "transition-[opacity,scale,translate] duration-180 ease-out-expo",
              "data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0",
              "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120",
              "data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
            )}
          >
            <form
              onSubmit={(e) => {
                e.preventDefault();
                if (!empty) onSubmit({ reasons: picked, comment: comment.trim() });
              }}
            >
              <div className="flex items-start justify-between gap-3">
                <Popover.Title className="text-[13px] font-medium tracking-[-0.01em]">What went wrong?</Popover.Title>
                <Popover.Close
                  aria-label="Skip feedback"
                  className={cn(
                    "relative -mr-1 -mt-1 grid size-6 shrink-0 place-items-center rounded-md text-fg-3 outline-none transition-[background-color,color,scale] duration-150",
                    "hover:bg-hover hover:text-fg active:scale-[0.9] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
                  )}
                >
                  <X size={14} />
                </Popover.Close>
              </div>
              <Popover.Description className="mt-0.5 text-[12px] text-fg-3">Pick any that apply. It helps the next answer.</Popover.Description>

              <div role="group" aria-label="Reasons" className="mt-3 flex flex-wrap gap-1.5">
                {reasons.map((r) => {
                  const on = picked.includes(r);
                  return (
                    <button
                      key={r}
                      type="button"
                      aria-pressed={on}
                      onClick={() => setPicked((p) => (on ? p.filter((x) => x !== r) : [...p, r]))}
                      className={cn(
                        "inline-flex h-7 select-none items-center gap-1 rounded-full border px-2.5 text-[12px] outline-none",
                        "transition-[background-color,border-color,color,scale,padding] duration-150 ease-out active:scale-[0.96] active:duration-75",
                        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                        on ? "border-fg bg-fg pl-1.5 text-frame" : "border-line-2 text-fg-2 hover:border-fg-4 hover:bg-hover hover:text-fg",
                      )}
                    >
                      <AnimatePresence initial={false}>
                        {on && (
                          <motion.span
                            key="check"
                            initial={{ width: 0, opacity: 0 }}
                            animate={{ width: 14, opacity: 1 }}
                            exit={{ width: 0, opacity: 0, transition: { duration: 0.12 } }}
                            transition={{ duration: 0.18, ease: ease.out }}
                            className="grid shrink-0 place-items-center overflow-hidden"
                          >
                            <Check size={14} />
                          </motion.span>
                        )}
                      </AnimatePresence>
                      {r}
                    </button>
                  );
                })}
              </div>

              <label htmlFor={`${id}-comment`} className="sr-only">
                Details
              </label>
              <textarea
                id={`${id}-comment`}
                value={comment}
                onChange={(e) => setComment(e.target.value)}
                rows={2}
                maxLength={500}
                placeholder="What should the answer have said? (optional)"
                className={cn(
                  "mt-3 block w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-2 text-base leading-5 text-fg outline-none sm:text-[13px]",
                  "placeholder:text-fg-4 transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-2 focus:ring-fg/10",
                )}
              />

              <div className="mt-3 flex items-center justify-end gap-2">
                <Popover.Close
                  className={cn(
                    "h-7 rounded-md px-2.5 text-[12px] font-medium text-fg-2 outline-none transition-[background-color,color,scale] duration-150",
                    "hover:bg-hover hover:text-fg active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                  )}
                >
                  Skip
                </Popover.Close>
                <button
                  type="submit"
                  disabled={empty}
                  className={cn(
                    "h-7 rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame outline-none transition-[background-color,opacity,scale] duration-150",
                    "hover:bg-fg/90 active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
                    "disabled:pointer-events-none disabled:opacity-40",
                  )}
                >
                  Send feedback
                </button>
              </div>
            </form>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

/* -------------------------------------------------------------------------------------------------
 * More: the overflow menu
 * -----------------------------------------------------------------------------------------------*/

export type MessageActionsMoreProps = {
  label?: string;
  align?: "start" | "center" | "end";
  /** `MessageActionsMenuItem`s and `MessageActionsMenuSeparator`s. */
  children: React.ReactNode;
};

export function MessageActionsMore({ label = "More actions", align = "start", children }: MessageActionsMoreProps) {
  const [popup, setPopup] = useState<HTMLDivElement | null>(null);
  const glide = useGlide(popup);

  return (
    <Menu.Root>
      <Tip label={label}>
        <Toolbar.Button render={<Menu.Trigger />} aria-label={label} className={buttonClass}>
          <MoreH />
        </Toolbar.Button>
      </Tip>
      <Menu.Portal>
        <Menu.Positioner side="bottom" align={align} sideOffset={6} collisionPadding={8} className="z-(--z-dropdown) outline-none">
          <Menu.Popup
            ref={setPopup}
            className={cn(
              "relative isolate min-w-48 max-w-[min(18rem,var(--available-width))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none",
              "transition-[opacity,scale,translate] duration-180 ease-out-expo",
              "data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0",
              "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120",
              "data-instant:duration-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
            )}
          >
            <motion.div
              aria-hidden
              style={glide}
              data-glide=""
              data-tone="default"
              className="pointer-events-none absolute inset-x-1 top-0 -z-10 rounded-lg bg-fg/[0.06] data-[tone=danger]:bg-danger-soft"
            />
            {children}
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Portal>
    </Menu.Root>
  );
}

export type MessageActionsMenuItemProps = Omit<Menu.Item.Props, "className"> & {
  icon?: React.ReactNode;
  variant?: "default" | "danger";
  /** A short hint on the right, like a shortcut. */
  hint?: React.ReactNode;
  className?: string;
};

export function MessageActionsMenuItem({ icon, variant = "default", hint, className, children, ...rest }: MessageActionsMenuItemProps) {
  return (
    <Menu.Item
      data-variant={variant}
      className={cn(
        "flex h-8 cursor-default select-none items-center gap-2 rounded-lg px-2 text-[13px] text-fg outline-none",
        "data-disabled:text-fg-4 data-[variant=danger]:text-danger",
        className,
      )}
      {...rest}
    >
      {icon && <span className="grid size-4 shrink-0 place-items-center text-fg-3 [[data-variant=danger]_&]:text-danger">{icon}</span>}
      <span className="min-w-0 flex-1 truncate">{children}</span>
      {hint && <span className="shrink-0 pl-3 font-mono text-[11px] text-fg-4">{hint}</span>}
    </Menu.Item>
  );
}

export function MessageActionsMenuSeparator({ className, ...rest }: Omit<Menu.Separator.Props, "className"> & { className?: string }) {
  return <Menu.Separator className={cn("mx-2 my-1 h-px bg-line", className)} {...rest} />;
}

// One highlight for the whole menu, moved to the highlighted row. It springs after the pointer
// and jumps for arrow keys, where motion would read as lag.
function useGlide(popup: HTMLDivElement | null) {
  const reduce = useReducedMotion();
  const y = useMotionValue(0);
  const height = useMotionValue(32);
  const opacity = useMotionValue(0);

  useEffect(() => {
    if (!popup) return;
    let keyboard = false;
    let shown = false;
    const hl = popup.querySelector<HTMLElement>("[data-glide]");
    const sync = () => {
      const row = popup.querySelector<HTMLElement>("[data-highlighted]");
      if (!row) {
        if (shown) animate(opacity, 0, { duration: 0.1 });
        shown = false;
        return;
      }
      if (hl) hl.dataset.tone = row.dataset.variant === "danger" ? "danger" : "default";
      if (!shown || keyboard || reduce) {
        y.jump(row.offsetTop);
        height.jump(row.offsetHeight);
      } else {
        animate(y, row.offsetTop, spring.follow);
        animate(height, row.offsetHeight, spring.follow);
      }
      if (!shown) animate(opacity, 1, { duration: keyboard || reduce ? 0 : 0.08 });
      shown = true;
    };
    const onKey = () => (keyboard = true);
    const onMove = () => (keyboard = false);
    const observer = new MutationObserver(sync);
    observer.observe(popup, { subtree: true, attributes: true, attributeFilter: ["data-highlighted"] });
    popup.addEventListener("keydown", onKey, true);
    popup.addEventListener("pointermove", onMove, true);
    sync();
    return () => {
      observer.disconnect();
      popup.removeEventListener("keydown", onKey, true);
      popup.removeEventListener("pointermove", onMove, true);
      opacity.jump(0);
    };
  }, [popup, reduce, y, height, opacity]);

  return { y, height, opacity };
}

05Props

MessageActions

PropTypeDefaultDescription
reveal"always" | "hover""always"hover hides the row until the nearest [data-message] ancestor or the row is hovered, or a control is keyboard-focused. Touch always shows it.
aria-labelstring"Message actions"Name of the toolbar.
...restToolbar.Root propsSpread onto the toolbar element.

MessageActionCopy

PropTypeDefaultDescription
value*string | () => string | Promise<string>The reply's markdown source, resolved at press time.
labelstring"Copy"Accessible name and resting tooltip.
copiedLabelstring"Copied"Tooltip and announcement after a copy.
failedLabelstring"Couldn’t copy"Tooltip and announcement when the clipboard refuses.
onCopied(text: string) => voidCalled with what reached the clipboard.
onError(error: unknown) => voidCalled when the copy fails.

MessageActionRegenerate

PropTypeDefaultDescription
onRegenerate() => void | Promise<unknown>Return a promise and the arrow keeps turning until it settles.
busybooleanControlled busy state, for when the new reply streams elsewhere. Presses are ignored while busy.
labelstring"Regenerate"Accessible name and tooltip.
busyLabelstring"Regenerating…"Name, tooltip and announcement while busy.

MessageActionFeedback

PropTypeDefaultDescription
value"up" | "down" | nullControlled rating.
defaultValue"up" | "down" | nullnullUncontrolled starting rating.
onValueChange(value) => voidCalled on every change, including un-rating.
reasonsstring[][]Offered in a follow-up after a thumbs down. Empty skips the follow-up.
onDetailsSubmit({ reasons, comment }) => voidCalled when the follow-up is sent.
upLabelstring"Good response"Name of the thumbs up.
downLabelstring"Bad response"Name of the thumbs down.
disabledbooleanfalseDisables both thumbs.

MessageAction

PropTypeDefaultDescription
label*stringAccessible name and tooltip. Say what it does.
tooltipReactNodeA different tooltip than the label.
keepbooleanfalseStay visible while the rest of a hover row is hidden.
children*ReactNodeThe icon.

MessageActionShare

PropTypeDefaultDescription
labelstring"Share"A MessageAction with the share icon. Takes the same props.

MessageActionsMore

PropTypeDefaultDescription
labelstring"More actions"Name of the overflow trigger.
align"start" | "center" | "end""start"Menu alignment against the trigger.
children*ReactNodeMessageActionsMenuItem and MessageActionsMenuSeparator.

MessageActionsMenuItem

PropTypeDefaultDescription
iconReactNode16px icon before the label.
variant"default" | "danger""default"Danger tints the row and its highlight.
hintReactNodeA shortcut or note on the right.
...restMenu.Item propsonClick, disabled, closeOnClick and the rest.

06Notes

Behavior

  • The row's height is always reserved; hover mode only fades opacity, so revealing it never nudges the thread.
  • A rated thumb, a fresh tick and a turning arrow stay visible while the rest of a hover row fades, so the result of what you did never disappears with your pointer.
  • Copy takes the markdown source, not the rendered text, and says so in the tooltip when the clipboard refuses.
  • Regenerate ignores presses while busy. Thumbs toggle: pressing the chosen one clears it; a thumbs down opens an optional follow-up that can be skipped with Escape.

Motion

  • Thumbs fill over 200ms while the icon nods 14° in its own direction and dips to 0.86, 420ms on the expo ease-out.
  • Regenerate turns once on the press (600ms in-out), loops linearly at 0.9s a turn while busy, then eases forward to the next full turn instead of snapping back.
  • Copy swaps icons on the pop spring and draws the tick in 300ms. Menus and the follow-up grow from their trigger in 180ms; the menu highlight glides on the follow spring and jumps for arrow keys.
  • Reduced motion drops the nod, the turn, the scale and the path draw, keeping 120ms fades and the fill.

Accessibility

  • A toolbar with one Tab stop; Left and Right move between actions, Home and End jump to the ends.
  • Every icon button has a name and a tooltip; thumbs are toggle buttons with aria-pressed.
  • A polite live region announces Copied, Regenerating…, the rating and Feedback sent.
  • The follow-up is a labelled popover; focus moves into it and returns to the thumbs down on close.