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** — Use it instead when the action applies to the whole message, like regenerate or edit-and-resubmit.
- **Inline Citation** — Use it as a positioning reference, since it anchors a hover-triggered popover to text in the same way.
- **Refinement Controls** — Use it as well so people can refine the whole output, not just a passage.
Do’s and don’ts
Do use it for
- 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).
Don’t use it for
- 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.
Related patterns
Chat Bubble with Actions
Use it instead when the action applies to the whole message, like regenerate or edit-and-resubmit.
Inline Citation
Use it as a positioning reference, since it anchors a hover-triggered popover to text in the same way.
Refinement Controls
Use it as well so people can refine the whole output, not just a passage.