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) => (
))}
{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 (