Inline assist The selection is the object, so nothing needs describing. A floating menu of verbs plus a free-text field, and the result arrives as a proposal with accept, discard and try again rather than replacing the selection outright. npx shadcn@latest add popover button input Tokens this needs: --card, --card-foreground, --popover, --popover-foreground, --primary, --primary-foreground, --muted-foreground, --border, --input, --chart-1 ──────────────────────────────────────────────────────────────────────── // InlineAssist.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; /** Short and concrete. Anything not on the list is typed into the field. */ export const VERBS = ["shorter", "tone", "grammar"] as const; export type Verb = (typeof VERBS)[number]; /** What was asked. Either way the selection is the object, so the request * never has to say what it is about. */ export type Request = { verb: Verb } | { instruction: string }; type Props = { /** The highlighted text. The whole permitted blast radius: what comes back * replaces this and nothing else. */ selection: string; open: boolean; onOpenChange: (open: boolean) => void; /** The app runs the model and hands the result back. null means nothing * has been proposed yet. */ proposal: string | null; pending?: boolean; onRequest: (request: Request) => void; /** The document changes here, once, and the original goes with it. */ onAccept: (text: string) => void; onDiscard: () => void; onRetry: () => void; }; /** Renders the selection in place, so it sits inside the paragraph it came * from. The menu hangs off it. */ export function InlineAssist({ selection, open, onOpenChange, proposal, pending, onRequest, onAccept, onDiscard, onRetry }: Props) { const [instruction, setInstruction] = useState(""); return ( {selection} e.preventDefault()} // Clicking away must not lose a proposal the viewer has not decided on. onInteractOutside={(e) => proposal && e.preventDefault()} >
{VERBS.map((verb) => ( ))}
{ e.preventDefault(); if (!instruction.trim()) return; onRequest({ instruction: instruction.trim() }); setInstruction(""); }} > setInstruction(e.target.value)} placeholder="or say what to do with it" aria-label="Instruction" className="h-8 text-xs" disabled={pending} />
{pending &&

rewriting the selection

} {/* The original stays in the document until one of these is pressed. */} {proposal && !pending && (

{proposal}

)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { InlineAssist, type Request } from "./InlineAssist"; const BEFORE = "Pricing. "; const SELECTED = "Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, we have a plan that fits your needs and your budget, with transparent pricing and no hidden fees."; const AFTER = " Starter is free for three seats. Team is £12 a seat a month. Enterprise is priced per seat on a contract."; /** What the model would send back for each request. Shorter is already on * screen, because that is the state the drawing shows. */ const REWRITES: Record = { shorter: "Pay for what you use. One plan, metered by seats, with the first three free.", tone: "We price by the seat, and the first three are on us. No tiers to compare, no fees you did not see coming.", grammar: "Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, there is a plan that fits your needs and your budget, with transparent pricing and no hidden fees.", }; /** The intro paragraph selected, the menu open on it, and "shorter" already * proposed. Accept swaps the selection only; the sentences either side stay. * One undo puts the original back. */ export default function Demo() { const [text, setText] = useState({ before: BEFORE, selected: SELECTED, after: AFTER }); const [previous, setPrevious] = useState(null); const [open, setOpen] = useState(true); const [proposal, setProposal] = useState(REWRITES.shorter); const [last, setLast] = useState({ verb: "shorter" }); const request = (r: Request) => { setLast(r); setProposal("verb" in r ? REWRITES[r.verb] : REWRITES.shorter); }; return (
{text.before} { setPrevious(text); setText({ ...text, selected: next }); setProposal(null); setOpen(false); }} onDiscard={() => { setProposal(null); setOpen(false); }} onRetry={() => request(last)} /> {text.after}
{previous && ( )}
); }