{"slug":"selection-actions","category":"text","job":"ask","added":"2026-09-12T13:51Z","title":"Selection Actions","description":"A floating toolbar on text selection for describing edits, explaining, or improving a passage.","url":"https://ai-patterns.app/patterns/text/selection-actions","markdown":"https://ai-patterns.app/patterns/text/selection-actions.md","json":"https://ai-patterns.app/api/patterns/text/selection-actions","seenIn":[{"team":"Notion","source":"https://www.notion.com/help/guides/notion-ai-for-docs"},{"team":"Google Gemini","source":"https://support.google.com/gemini/answer/14262426"},{"team":"chatgpt","source":"https://help.openai.com/en/articles/9930697-what-is-the-canvas-feature-in-chatgpt-and-how-do-i-use-it"},{"team":"Figma","source":"https://help.figma.com/hc/en-us/articles/42009840449175-Edit-a-Figma-Make-file"},{"team":"GitHub Copilot","source":"https://docs.github.com/copilot/using-github-copilot/asking-github-copilot-questions-in-your-ide"},{"team":"lovable","source":"https://docs.lovable.dev/features/visual-edit"}],"spec":"# Selection Actions\n\n## Summary\nA 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.\n\n## When to use\n- 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.\n- 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.\n- 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).\n\n## When not to use\n- 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.\n- 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.\n- 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.\n\n## Anatomy\n- 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\").\n- 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.\n- 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.\n\n## Behavior\n- 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.\n- 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.\n- Explain does not enter the review flow — it opens an inline answer panel below the toolbar and leaves the original text untouched.\n- While reviewing a candidate, new selections are disabled; the user must Keep or Discard first.\n- 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.\n- Clicking outside the toolbar and the selected passage, or pressing Escape, dismisses the toolbar and clears the selection without making any change.\n\n## Content guidelines\n- 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.\n- The Explain response should describe intent or reasoning (\"this sets the deadline because—\"), not just restate the sentence in other words.\n- Default the Improve action to a clear, generic instruction (\"improve clarity and flow\") so it's useful without the user typing anything.\n\n## Accessibility\n- 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.\n- The candidate highlight sets `aria-busy` while generating so assistive tech doesn't announce placeholder content as final.\n- The explanation panel uses `role=\"status\"` so screen reader users hear the answer as it appears without needing to navigate to it.\n- Escape and outside-click both dismiss the toolbar, matching how any other transient popover on the page behaves.\n\n## Related patterns\n- **Chat Bubble with Actions** — Use it instead when the action applies to the whole message, like regenerate or edit-and-resubmit.\n- **Inline Citation** — Use it as a positioning reference, since it anchors a hover-triggered popover to text in the same way.\n- **Refinement Controls** — Use it as well so people can refine the whole output, not just a passage.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Check, ChevronRight, CircleHelp, Loader2, RotateCcw, Sparkles, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface SelectionActionsProps {\n  text: string;\n  onExplain?: (selection: string) => string | Promise<string>;\n  onRewrite: (selection: string, instruction: string) => string | Promise<string>;\n  className?: string;\n}\n\nconst QUICK_EDITS = [\"Fix grammar\", \"Shorten\", \"Make more formal\"];\nconst TOOLBAR_WIDTH = 320;\nconst REVIEW_WIDTH = 200;\nconst MARGIN = 8;\nconst DEFAULT_INSTRUCTION = \"Improve the clarity and flow of this passage.\";\n\ninterface Anchor {\n  top: number;\n  bottom: number;\n  left: number;\n}\n\ninterface Selection {\n  start: number;\n  end: number;\n  text: string;\n}\n\ninterface Review {\n  start: number;\n  end: number;\n  original: string;\n  instruction: string;\n  candidate: string | null;\n}\n\nexport function SelectionActions({ text, onExplain, onRewrite, className }: SelectionActionsProps) {\n  const [value, setValue] = React.useState(text);\n  const [selection, setSelection] = React.useState<Selection | null>(null);\n  const [anchor, setAnchor] = React.useState<Anchor | null>(null);\n  const [instruction, setInstruction] = React.useState(\"\");\n  const [expanded, setExpanded] = React.useState(false);\n  const [explanation, setExplanation] = React.useState<string | null>(null);\n  const [review, setReview] = React.useState<Review | null>(null);\n  const [reviewAnchor, setReviewAnchor] = React.useState<Anchor | null>(null);\n\n  const containerRef = React.useRef<HTMLParagraphElement>(null);\n  const toolbarRef = React.useRef<HTMLDivElement>(null);\n  const candidateRef = React.useRef<HTMLElement>(null);\n  const mounted = useMounted();\n\n  const clearSelectionState = React.useCallback(() => {\n    setSelection(null);\n    setAnchor(null);\n    setInstruction(\"\");\n    setExpanded(false);\n    setExplanation(null);\n  }, []);\n\n  const updateFromDom = React.useCallback(\n    (target: Node) => {\n      const container = containerRef.current;\n      const insideToolbar = toolbarRef.current?.contains(target);\n      const insideContainer = container?.contains(target);\n\n      if (!insideToolbar && !insideContainer) {\n        clearSelectionState();\n        return;\n      }\n      if (insideToolbar) return;\n\n      const sel = window.getSelection();\n      if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !container) {\n        clearSelectionState();\n        return;\n      }\n      const range = sel.getRangeAt(0);\n      if (!container.contains(range.commonAncestorContainer)) {\n        clearSelectionState();\n        return;\n      }\n      const { start, end } = offsetsWithin(container, range);\n      if (start === end) {\n        clearSelectionState();\n        return;\n      }\n      const rect = range.getBoundingClientRect();\n      setSelection({ start, end, text: value.slice(start, end) });\n      setAnchor({ top: rect.top, bottom: rect.bottom, left: rect.left + rect.width / 2 });\n      setExpanded(false);\n      setExplanation(null);\n    },\n    [value, clearSelectionState]\n  );\n\n  React.useEffect(() => {\n    if (review) return;\n    function handlePointerUp(e: PointerEvent) {\n      updateFromDom(e.target as Node);\n    }\n    function handleKeyUp(e: KeyboardEvent) {\n      if (e.key === \"Escape\") {\n        window.getSelection()?.removeAllRanges();\n        clearSelectionState();\n        return;\n      }\n      if (e.key.startsWith(\"Arrow\") || e.key === \"Shift\") updateFromDom(e.target as Node);\n    }\n    document.addEventListener(\"pointerup\", handlePointerUp);\n    document.addEventListener(\"keyup\", handleKeyUp);\n    return () => {\n      document.removeEventListener(\"pointerup\", handlePointerUp);\n      document.removeEventListener(\"keyup\", handleKeyUp);\n    };\n  }, [review, updateFromDom, clearSelectionState]);\n\n  React.useLayoutEffect(() => {\n    if (!review || !candidateRef.current) return;\n    const rect = candidateRef.current.getBoundingClientRect();\n    setReviewAnchor({ top: rect.top, bottom: rect.bottom, left: rect.left + rect.width / 2 });\n  }, [review, review?.candidate]);\n\n  async function runRewrite(rawInstruction: string) {\n    if (!selection) return;\n    const instructionToUse = rawInstruction.trim() || DEFAULT_INSTRUCTION;\n    const { start, end, text: original } = selection;\n    window.getSelection()?.removeAllRanges();\n    clearSelectionState();\n    setReview({ start, end, original, instruction: instructionToUse, candidate: null });\n    const result = await onRewrite(original, instructionToUse);\n    setReview((prev) => (prev && prev.start === start && prev.end === end ? { ...prev, candidate: result } : prev));\n  }\n\n  function retry() {\n    if (!review) return;\n    const { start, end, original, instruction: usedInstruction } = review;\n    setReview((prev) => (prev ? { ...prev, candidate: null } : prev));\n    Promise.resolve(onRewrite(original, usedInstruction)).then((result) => {\n      setReview((prev) => (prev && prev.start === start && prev.end === end ? { ...prev, candidate: result } : prev));\n    });\n  }\n\n  function keep() {\n    if (!review || review.candidate == null) return;\n    const { start, end, candidate } = review;\n    setValue((prev) => prev.slice(0, start) + candidate + prev.slice(end));\n    setReview(null);\n    setReviewAnchor(null);\n  }\n\n  function discard() {\n    setReview(null);\n    setReviewAnchor(null);\n  }\n\n  async function explain() {\n    if (!selection || !onExplain) return;\n    const result = await onExplain(selection.text);\n    setExplanation(result);\n  }\n\n  return (\n    <div className={cn(\"relative w-full\", className)}>\n      <p ref={containerRef} className=\"select-text text-sm leading-relaxed text-foreground/90\">\n        {renderContent(value, review, candidateRef)}\n      </p>\n\n      {mounted &&\n        createPortal(\n          <>\n            <AnimatePresence>\n              {selection && anchor && !review && (\n                <FloatingToolbar\n                  ref={toolbarRef}\n                  anchor={anchor}\n                  instruction={instruction}\n                  onInstructionChange={setInstruction}\n                  onSubmitInstruction={() => runRewrite(instruction)}\n                  hasExplain={!!onExplain}\n                  onExplain={explain}\n                  onImprove={() => runRewrite(DEFAULT_INSTRUCTION)}\n                  onQuickEdit={runRewrite}\n                  expanded={expanded}\n                  onToggleExpanded={() => setExpanded((v) => !v)}\n                  explanation={explanation}\n                />\n              )}\n            </AnimatePresence>\n            <AnimatePresence>\n              {review && reviewAnchor && (\n                <ReviewToolbar\n                  anchor={reviewAnchor}\n                  loading={review.candidate == null}\n                  onKeep={keep}\n                  onDiscard={discard}\n                  onRetry={retry}\n                />\n              )}\n            </AnimatePresence>\n          </>,\n          document.body\n        )}\n    </div>\n  );\n}\n\nfunction renderContent(\n  value: string,\n  review: Review | null,\n  candidateRef: React.RefObject<HTMLElement | null>\n) {\n  if (!review) return value;\n\n  const before = value.slice(0, review.start);\n  const after = value.slice(review.end);\n  const loading = review.candidate == null;\n\n  return (\n    <>\n      {before}\n      <mark\n        ref={candidateRef}\n        aria-busy={loading}\n        className={cn(\n          \"rounded px-0.5\",\n          loading\n            ? \"animate-pulse bg-muted text-muted-foreground\"\n            : \"bg-blue-100 text-foreground dark:bg-blue-500/20\"\n        )}\n      >\n        {loading ? review.original : review.candidate}\n      </mark>\n      {after}\n    </>\n  );\n}\n\nconst FloatingToolbar = React.forwardRef<\n  HTMLDivElement,\n  {\n    anchor: Anchor;\n    instruction: string;\n    onInstructionChange: (v: string) => void;\n    onSubmitInstruction: () => void;\n    hasExplain: boolean;\n    onExplain: () => void;\n    onImprove: () => void;\n    onQuickEdit: (instruction: string) => void;\n    expanded: boolean;\n    onToggleExpanded: () => void;\n    explanation: string | null;\n  }\n>(function FloatingToolbar(\n  {\n    anchor,\n    instruction,\n    onInstructionChange,\n    onSubmitInstruction,\n    hasExplain,\n    onExplain,\n    onImprove,\n    onQuickEdit,\n    expanded,\n    onToggleExpanded,\n    explanation,\n  },\n  ref\n) {\n  const left = clampCenterX(anchor.left, TOOLBAR_WIDTH);\n\n  return (\n    <motion.div\n      ref={ref}\n      role=\"toolbar\"\n      aria-label=\"Selection actions\"\n      initial={{ opacity: 0, y: -4 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: -4 }}\n      transition={{ duration: 0.14 }}\n      style={{ position: \"fixed\", top: anchor.bottom + MARGIN, left, width: TOOLBAR_WIDTH, transform: \"translateX(-50%)\" }}\n      className=\"z-50\"\n    >\n      <div className=\"flex items-center gap-1 rounded-full border bg-popover px-1.5 py-1 shadow-md\">\n        <input\n          value={instruction}\n          onChange={(e) => onInstructionChange(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && instruction.trim()) onSubmitInstruction();\n          }}\n          placeholder=\"Describe edits\"\n          aria-label=\"Describe edits\"\n          className=\"min-w-0 flex-1 bg-transparent px-2 py-1 text-xs text-foreground outline-none placeholder:text-muted-foreground\"\n        />\n        {hasExplain && (\n          <>\n            <Divider />\n            <ToolbarButton icon={CircleHelp} label=\"Explain\" onClick={onExplain} />\n          </>\n        )}\n        <Divider />\n        <ToolbarButton icon={Sparkles} label=\"Improve\" onClick={onImprove} />\n        <Divider />\n        <button\n          type=\"button\"\n          aria-label={expanded ? \"Fewer quick edits\" : \"More quick edits\"}\n          aria-expanded={expanded}\n          onClick={onToggleExpanded}\n          className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n        >\n          <ChevronRight className={cn(\"size-4 transition-transform\", expanded && \"rotate-90\")} aria-hidden />\n        </button>\n      </div>\n\n      <AnimatePresence>\n        {expanded && (\n          <motion.div\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            transition={{ duration: 0.12 }}\n            className=\"mt-1.5 overflow-hidden rounded-xl border bg-popover shadow-md\"\n          >\n            {QUICK_EDITS.map((q) => (\n              <button\n                key={q}\n                type=\"button\"\n                onClick={() => onQuickEdit(q)}\n                className=\"block w-full px-3 py-2 text-left text-xs text-foreground/90 hover:bg-accent\"\n              >\n                {q}\n              </button>\n            ))}\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence>\n        {explanation && (\n          <motion.div\n            role=\"status\"\n            initial={{ opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            transition={{ duration: 0.12 }}\n            className=\"mt-1.5 rounded-xl border bg-popover p-3 text-xs text-foreground/90 shadow-md\"\n          >\n            {explanation}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  );\n});\n\nfunction ToolbarButton({\n  icon: Icon,\n  label,\n  onClick,\n}: {\n  icon: React.ComponentType<{ className?: string }>;\n  label: string;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      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\"\n    >\n      <Icon className=\"size-4\" aria-hidden />\n      {label}\n    </button>\n  );\n}\n\nfunction Divider() {\n  return <span className=\"h-4 w-px shrink-0 bg-border\" aria-hidden />;\n}\n\nfunction ReviewToolbar({\n  anchor,\n  loading,\n  onKeep,\n  onDiscard,\n  onRetry,\n}: {\n  anchor: Anchor;\n  loading: boolean;\n  onKeep: () => void;\n  onDiscard: () => void;\n  onRetry: () => void;\n}) {\n  const left = clampCenterX(anchor.left, REVIEW_WIDTH);\n\n  return (\n    <motion.div\n      role=\"toolbar\"\n      aria-label=\"Review suggested edit\"\n      initial={{ opacity: 0, y: -4 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: -4 }}\n      transition={{ duration: 0.14 }}\n      style={{ position: \"fixed\", top: anchor.bottom + MARGIN, left, transform: \"translateX(-50%)\" }}\n      className=\"z-50 flex items-center gap-1 rounded-full border bg-popover p-1 shadow-md\"\n    >\n      <button\n        type=\"button\"\n        disabled={loading}\n        onClick={onKeep}\n        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\"\n      >\n        <Check className=\"size-3.5\" aria-hidden />\n        Keep\n      </button>\n      <button\n        type=\"button\"\n        onClick={onDiscard}\n        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\"\n      >\n        <X className=\"size-3.5\" aria-hidden />\n        Discard\n      </button>\n      <span className=\"h-4 w-px bg-border\" aria-hidden />\n      <button\n        type=\"button\"\n        aria-label=\"Retry\"\n        disabled={loading}\n        onClick={onRetry}\n        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\"\n      >\n        {loading ? <Loader2 className=\"size-3.5 animate-spin\" aria-hidden /> : <RotateCcw className=\"size-3.5\" aria-hidden />}\n      </button>\n    </motion.div>\n  );\n}\n\nfunction offsetsWithin(container: HTMLElement, range: Range) {\n  const pre = document.createRange();\n  pre.selectNodeContents(container);\n  pre.setEnd(range.startContainer, range.startOffset);\n  const start = pre.toString().length;\n  return { start, end: start + range.toString().length };\n}\n\nfunction clampCenterX(centerX: number, width: number) {\n  if (typeof window === \"undefined\") return centerX;\n  return Math.min(Math.max(centerX, width / 2 + MARGIN), window.innerWidth - width / 2 - MARGIN);\n}\n\n/** True only once the client has rendered — lets a portal target `document.body` without an SSR mismatch. */\nfunction useMounted() {\n  return React.useSyncExternalStore(\n    () => () => {},\n    () => true,\n    () => false\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}