text
Selection Actions
A floating toolbar on text selection for describing edits, explaining, or improving a passage.
Turn on the thinking loader the moment the agent starts a step, and keep it up until the trace is fully ready to show so users never sit through a blank pause.
Highlight a passage above to try Explain, Improve, or a custom edit.
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronRight, CircleHelp, Loader2, RotateCcw, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectionActionsProps {
text: string;
onExplain?: (selection: string) => string | Promise<string>;
onRewrite: (selection: string, instruction: string) => string | Promise<string>;
className?: string;
}
const QUICK_EDITS = ["Fix grammar", "Shorten", "Make more formal"];
const TOOLBAR_WIDTH = 320;
const REVIEW_WIDTH = 200;
const MARGIN = 8;
const DEFAULT_INSTRUCTION = "Improve the clarity and flow of this passage.";
interface Anchor {
top: number;
bottom: number;
left: number;
}
interface Selection {
start: number;
end: number;
text: string;
}
interface Review {
start: number;
end: number;
original: string;
instruction: string;
candidate: string | null;
}
export function SelectionActions({ text, onExplain, onRewrite, className }: SelectionActionsProps) {
const [value, setValue] = React.useState(text);
const [selection, setSelection] = React.useState<Selection | null>(null);
const [anchor, setAnchor] = React.useState<Anchor | null>(null);
const [instruction, setInstruction] = React.useState("");
const [expanded, setExpanded] = React.useState(false);
const [explanation, setExplanation] = React.useState<string | null>(null);
const [review, setReview] = React.useState<Review | null>(null);
const [reviewAnchor, setReviewAnchor] = React.useState<Anchor | null>(null);
const containerRef = React.useRef<HTMLParagraphElement>(null);
const toolbarRef = React.useRef<HTMLDivElement>(null);
const candidateRef = React.useRef<HTMLElement>(null);
const mounted = useMounted();
const clearSelectionState = React.useCallback(() => {
setSelection(null);
setAnchor(null);
setInstruction("");
setExpanded(false);
setExplanation(null);
}, []);
const updateFromDom = React.useCallback(
(target: Node) => {
const container = containerRef.current;
const insideToolbar = toolbarRef.current?.contains(target);
const insideContainer = container?.contains(target);
if (!insideToolbar && !insideContainer) {
clearSelectionState();
return;
}
if (insideToolbar) return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !container) {
clearSelectionState();
return;
}
const range = sel.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
clearSelectionState();
return;
}
const { start, end } = offsetsWithin(container, range);
if (start === end) {
clearSelectionState();
return;
}
const rect = range.getBoundingClientRect();
setSelection({ start, end, text: value.slice(start, end) });
setAnchor({ top: rect.top, bottom: rect.bottom, left: rect.left + rect.width / 2 });
setExpanded(false);
setExplanation(null);
},
[value, clearSelectionState]
);
React.useEffect(() => {
if (review) return;
function handlePointerUp(e: PointerEvent) {
updateFromDom(e.target as Node);
}
function handleKeyUp(e: KeyboardEvent) {
if (e.key === "Escape") {
window.getSelection()?.removeAllRanges();
clearSelectionState();
return;
}
if (e.key.startsWith("Arrow") || e.key === "Shift") updateFromDom(e.target as Node);
}
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("keyup", handleKeyUp);
};
}, [review, updateFromDom, clearSelectionState]);
React.useLayoutEffect(() => {
if (!review || !candidateRef.current) return;
const rect = candidateRef.current.getBoundingClientRect();
setReviewAnchor({ top: rect.top, bottom: rect.bottom, left: rect.left + rect.width / 2 });
}, [review, review?.candidate]);
async function runRewrite(rawInstruction: string) {
if (!selection) return;
const instructionToUse = rawInstruction.trim() || DEFAULT_INSTRUCTION;
const { start, end, text: original } = selection;
window.getSelection()?.removeAllRanges();
clearSelectionState();
setReview({ start, end, original, instruction: instructionToUse, candidate: null });
const result = await onRewrite(original, instructionToUse);
setReview((prev) => (prev && prev.start === start && prev.end === end ? { ...prev, candidate: result } : prev));
}
function retry() {
if (!review) return;
const { start, end, original, instruction: usedInstruction } = review;
setReview((prev) => (prev ? { ...prev, candidate: null } : prev));
Promise.resolve(onRewrite(original, usedInstruction)).then((result) => {
setReview((prev) => (prev && prev.start === start && prev.end === end ? { ...prev, candidate: result } : prev));
});
}
function keep() {
if (!review || review.candidate == null) return;
const { start, end, candidate } = review;
setValue((prev) => prev.slice(0, start) + candidate + prev.slice(end));
setReview(null);
setReviewAnchor(null);
}
function discard() {
setReview(null);
setReviewAnchor(null);
}
async function explain() {
if (!selection || !onExplain) return;
const result = await onExplain(selection.text);
setExplanation(result);
}
return (
<div className={cn("relative w-full", className)}>
<p ref={containerRef} className="select-text text-sm leading-relaxed text-foreground/90">
{renderContent(value, review, candidateRef)}
</p>
{mounted &&
createPortal(
<>
<AnimatePresence>
{selection && anchor && !review && (
<FloatingToolbar
ref={toolbarRef}
anchor={anchor}
instruction={instruction}
onInstructionChange={setInstruction}
onSubmitInstruction={() => runRewrite(instruction)}
hasExplain={!!onExplain}
onExplain={explain}
onImprove={() => runRewrite(DEFAULT_INSTRUCTION)}
onQuickEdit={runRewrite}
expanded={expanded}
onToggleExpanded={() => setExpanded((v) => !v)}
explanation={explanation}
/>
)}
</AnimatePresence>
<AnimatePresence>
{review && reviewAnchor && (
<ReviewToolbar
anchor={reviewAnchor}
loading={review.candidate == null}
onKeep={keep}
onDiscard={discard}
onRetry={retry}
/>
)}
</AnimatePresence>
</>,
document.body
)}
</div>
);
}
function renderContent(
value: string,
review: Review | null,
candidateRef: React.RefObject<HTMLElement | null>
) {
if (!review) return value;
const before = value.slice(0, review.start);
const after = value.slice(review.end);
const loading = review.candidate == null;
return (
<>
{before}
<mark
ref={candidateRef}
aria-busy={loading}
className={cn(
"rounded px-0.5",
loading
? "animate-pulse bg-muted text-muted-foreground"
: "bg-blue-100 text-foreground dark:bg-blue-500/20"
)}
>
{loading ? review.original : review.candidate}
</mark>
{after}
</>
);
}
const FloatingToolbar = React.forwardRef<
HTMLDivElement,
{
anchor: Anchor;
instruction: string;
onInstructionChange: (v: string) => void;
onSubmitInstruction: () => void;
hasExplain: boolean;
onExplain: () => void;
onImprove: () => void;
onQuickEdit: (instruction: string) => void;
expanded: boolean;
onToggleExpanded: () => void;
explanation: string | null;
}
>(function FloatingToolbar(
{
anchor,
instruction,
onInstructionChange,
onSubmitInstruction,
hasExplain,
onExplain,
onImprove,
onQuickEdit,
expanded,
onToggleExpanded,
explanation,
},
ref
) {
const left = clampCenterX(anchor.left, TOOLBAR_WIDTH);
return (
<motion.div
ref={ref}
role="toolbar"
aria-label="Selection actions"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.14 }}
style={{ position: "fixed", top: anchor.bottom + MARGIN, left, width: TOOLBAR_WIDTH, transform: "translateX(-50%)" }}
className="z-50"
>
<div className="flex items-center gap-1 rounded-full border bg-popover px-1.5 py-1 shadow-md">
<input
value={instruction}
onChange={(e) => onInstructionChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && instruction.trim()) onSubmitInstruction();
}}
placeholder="Describe edits"
aria-label="Describe edits"
className="min-w-0 flex-1 bg-transparent px-2 py-1 text-xs text-foreground outline-none placeholder:text-muted-foreground"
/>
{hasExplain && (
<>
<Divider />
<ToolbarButton icon={CircleHelp} label="Explain" onClick={onExplain} />
</>
)}
<Divider />
<ToolbarButton icon={Sparkles} label="Improve" onClick={onImprove} />
<Divider />
<button
type="button"
aria-label={expanded ? "Fewer quick edits" : "More quick edits"}
aria-expanded={expanded}
onClick={onToggleExpanded}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronRight className={cn("size-4 transition-transform", expanded && "rotate-90")} aria-hidden />
</button>
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12 }}
className="mt-1.5 overflow-hidden rounded-xl border bg-popover shadow-md"
>
{QUICK_EDITS.map((q) => (
<button
key={q}
type="button"
onClick={() => onQuickEdit(q)}
className="block w-full px-3 py-2 text-left text-xs text-foreground/90 hover:bg-accent"
>
{q}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{explanation && (
<motion.div
role="status"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12 }}
className="mt-1.5 rounded-xl border bg-popover p-3 text-xs text-foreground/90 shadow-md"
>
{explanation}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
});
function ToolbarButton({
icon: Icon,
label,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Icon className="size-4" aria-hidden />
{label}
</button>
);
}
function Divider() {
return <span className="h-4 w-px shrink-0 bg-border" aria-hidden />;
}
function ReviewToolbar({
anchor,
loading,
onKeep,
onDiscard,
onRetry,
}: {
anchor: Anchor;
loading: boolean;
onKeep: () => void;
onDiscard: () => void;
onRetry: () => void;
}) {
const left = clampCenterX(anchor.left, REVIEW_WIDTH);
return (
<motion.div
role="toolbar"
aria-label="Review suggested edit"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.14 }}
style={{ position: "fixed", top: anchor.bottom + MARGIN, left, transform: "translateX(-50%)" }}
className="z-50 flex items-center gap-1 rounded-full border bg-popover p-1 shadow-md"
>
<button
type="button"
disabled={loading}
onClick={onKeep}
className="flex items-center gap-1.5 rounded-full bg-foreground px-3.5 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90 disabled:pointer-events-none disabled:opacity-50"
>
<Check className="size-3.5" aria-hidden />
Keep
</button>
<button
type="button"
onClick={onDiscard}
className="flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="size-3.5" aria-hidden />
Discard
</button>
<span className="h-4 w-px bg-border" aria-hidden />
<button
type="button"
aria-label="Retry"
disabled={loading}
onClick={onRetry}
className="flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
{loading ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : <RotateCcw className="size-3.5" aria-hidden />}
</button>
</motion.div>
);
}
function offsetsWithin(container: HTMLElement, range: Range) {
const pre = document.createRange();
pre.selectNodeContents(container);
pre.setEnd(range.startContainer, range.startOffset);
const start = pre.toString().length;
return { start, end: start + range.toString().length };
}
function clampCenterX(centerX: number, width: number) {
if (typeof window === "undefined") return centerX;
return Math.min(Math.max(centerX, width / 2 + MARGIN), window.innerWidth - width / 2 - MARGIN);
}
/** True only once the client has rendered — lets a portal target `document.body` without an SSR mismatch. */
function useMounted() {
return React.useSyncExternalStore(
() => () => {},
() => true,
() => false
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Selection Actions
## Summary
A floating toolbar that appears when the user highlights a passage of agent-written text, letting them describe an edit, ask for an explanation, or request an improvement without leaving the reading flow. Submitting an action replaces the highlight with a live, editable suggestion — shown inline with a subtle highlight — that the user keeps, discards, or retries before it lands in the text.
## When to use
- Over any block of agent-generated text the user is expected to revise in place — a drafted email, a generated paragraph, a summary — rather than regenerating the whole response.
- When edits are naturally scoped to a phrase or sentence rather than the whole document, so anchoring the toolbar to the selection reads more directly than a document-level "regenerate" action.
- When the user benefits from a quick, low-commitment way to ask "why did you write this" (Explain) alongside ways to change it (Improve, a custom instruction, or a quick-edit shortcut).
## When not to use
- On text the user can't or shouldn't edit (system messages, another person's message, read-only citations) — offering a rewrite toolbar implies the content is theirs to change.
- For whole-document actions like "regenerate this response" or "translate the whole thing" — those belong on a persistent action bar for the message, not a selection-anchored popup.
- On very short text (a label, a single word) where a highlight-triggered toolbar has more visual weight than the content it's acting on.
## Anatomy
- A pill-shaped toolbar anchored below the current selection: a borderless "Describe edits" text field on the left, a vertical divider, an "Explain" button (circle-help icon), another divider, an "Improve" button (sparkle icon), a final divider, and a chevron that expands a short list of quick edits (e.g. "Fix grammar," "Shorten," "Make more formal").
- Once an edit is requested, the toolbar is replaced by the candidate text rendered inline with a highlight (a muted pulsing highlight while generating, a colored highlight once ready) directly in place of the original passage.
- Below the candidate, a second pill toolbar appears: a filled "Keep" button, a plain "Discard" button, and a small icon-only "Retry" button, separated the same way as the first toolbar.
## Behavior
- The toolbar appears only once a selection is finalized (on pointer-up or after a keyboard selection), not while the user is still dragging — so it doesn't jitter mid-drag.
- Clicking Explain, Improve, a quick edit, or submitting the instruction field immediately swaps the selection for the review state: the original selection is replaced by an inline highlighted placeholder while the request is in flight, then by the candidate text once it resolves.
- Explain does not enter the review flow — it opens an inline answer panel below the toolbar and leaves the original text untouched.
- While reviewing a candidate, new selections are disabled; the user must Keep or Discard first.
- Keep commits the candidate into the underlying text and closes the toolbar. Discard reverts to the original passage with no trace of the attempt. Retry re-runs the same instruction and shows the loading highlight again without discarding the review state.
- Clicking outside the toolbar and the selected passage, or pressing Escape, dismisses the toolbar and clears the selection without making any change.
## Content guidelines
- Keep quick-edit labels as short verb phrases ("Fix grammar," "Shorten") the user can scan in under a second — they're shortcuts, not full instructions.
- The Explain response should describe intent or reasoning ("this sets the deadline because—"), not just restate the sentence in other words.
- Default the Improve action to a clear, generic instruction ("improve clarity and flow") so it's useful without the user typing anything.
## Accessibility
- Both toolbars have `role="toolbar"` with a descriptive `aria-label`, and the instruction field carries its own `aria-label` since its only visible label is placeholder text.
- The candidate highlight sets `aria-busy` while generating so assistive tech doesn't announce placeholder content as final.
- The explanation panel uses `role="status"` so screen reader users hear the answer as it appears without needing to navigate to it.
- Escape and outside-click both dismiss the toolbar, matching how any other transient popover on the page behaves.
## Related patterns
- Chat Bubble with Actions covers whole-message actions (regenerate, edit-and-resubmit) — reach for Selection Actions when the edit is scoped to part of a message instead of the whole thing.
- Inline Citation uses the same anchored-popover positioning technique for a hover-triggered footnote instead of a click-triggered action toolbar.