Skip to content
KONIGI Patterns

AI Assistants / Help within apps / Inline assist

‹ 4 of 5 ›

Inline assist

People need to revise selected text without leaving the document.

Updated September 12, 2026

Problem

Fixing text through a separate chat requires copying it out and pasting the result back. People need to make changes where they are already writing.

Solution

Let people select text and request a change in place. Offer a short menu of clear actions, such as shortening text or fixing grammar, plus a field for other requests.

Provide both a visible control and a keyboard shortcut. Show the proposed result before replacing the original, with options to accept, discard, or try again.

Limit changes to the selected content. Keep the original formatting where possible, and let people undo the accepted change in one step. Keep common edits fast enough to avoid interrupting the writing task.

Use when

People use an assistant inside an editor to revise their own text.

Don’t use when

The task needs conversation to pin down. An ambiguous request, a multi-step change, or anything needing back-and-forth belongs in a panel where a thread can develop, since an inline popover with one input has no room for the second turn.

Trade-offs

A selection gives a clear boundary, but some edits need changes beyond it. Reviewing every proposal adds a step. Selection controls can also overlap the editor’s formatting toolbar or the device’s text-selection menu.

Checklist

  • Is the selection the only thing that changes?
  • Are accept, discard and try-again all available before the document changes?
  • Does a single undo reverse the whole operation?
  • Is there a keyboard path, or only a floating control?
  • What happens on a selection spanning several blocks, a table, or an image?
  • How long does the common case take, and is that fast enough to hold a thought?
  • Does the control collide with the editor’s existing selection toolbar?
  • Can the viewer type a free-text instruction as well as pick a verb?
  • What does the assist see beyond the selection, and is that stated?
  • Does it work in a read-only document, and should it?

Compare

Notion runs the full triad on a selection, offering fixes and tone changes with accept, discard or retry before anything is written, and pairs it with an @-mention so other pages can be pulled in as context. Google Docs puts the equivalent at the cursor for generation and keeps the refinement controls attached to the produced block rather than the selection. Linear scopes its assist to the object rather than the prose, acting on an issue as a record instead of on highlighted characters. Figma applies the same idea to non-text objects, where the selection is a frame and the verbs are structural. Very little of this pattern is actually about writing.

Suggestion diff highlights proposed changes. Ghost text offers completions while people type. Assistant sidebar supports requests that need a longer conversation.

AI entry point helps people find these actions. Prompt starters suggest requests for selected content.

Inline assist anatomy A paragraph selected inside a document with a floating verb menu above it and a free-text field below, and the result presented as a proposal with accept, discard and try-again rather than replacing the selection outright. The selection is the object, so nothing needs describing shorter tone grammar accept discard try again 1 2 3 4 1 THE SELECTION Already identified by the cursor, so the viewer describes nothing. 2 VERBS AND A FIELD A short concrete list for the common cases, free text for everything else. 3 PROPOSE, DO NOT REPLACE The original survives until a decision is made, and one undo reverses all of it. 4 THE BLAST RADIUS Asked to fix one sentence and returning a rewritten paragraph breaks the contract. Latency matters more here than in chat. The viewer is waiting inside their own sentence.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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.

shadcn
npx shadcn@latest add popover button input
Tokens
--card--card-foreground--popover--popover-foreground--primary--primary-foreground--muted-foreground--border--input--chart-1
Pricing. 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.
Starter is free for three seats. Team is £12 a seat a month. Enterprise is priced per seat on a contract.

InlineAssist.tsxThe selection rendered in place with the menu hung off it: three verbs, a field, and the proposal with accept, discard and try again. Accept is the only thing that touches the document.

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 (
    <Popover open={open} onOpenChange={onOpenChange} modal={false}>
      <PopoverAnchor asChild>
        <mark className="rounded-[2px] bg-chart-1/20 text-inherit">{selection}</mark>
      </PopoverAnchor>
      <PopoverContent
        side="bottom"
        align="start"
        className="w-[404px] max-w-[calc(100vw-2rem)] p-3"
        onOpenAutoFocus={(e) => e.preventDefault()}
        // Clicking away must not lose a proposal the viewer has not decided on.
        onInteractOutside={(e) => proposal && e.preventDefault()}
      >
        <div className="flex flex-wrap gap-2">
          {VERBS.map((verb) => (
            <Button key={verb} variant="outline" size="sm" className="h-7 rounded-full text-xs" disabled={pending} onClick={() => onRequest({ verb })}>
              {verb}
            </Button>
          ))}
        </div>
        <form
          className="mt-3"
          onSubmit={(e) => {
            e.preventDefault();
            if (!instruction.trim()) return;
            onRequest({ instruction: instruction.trim() });
            setInstruction("");
          }}
        >
          <Input value={instruction} onChange={(e) => setInstruction(e.target.value)} placeholder="or say what to do with it" aria-label="Instruction" className="h-8 text-xs" disabled={pending} />
        </form>

        {pending && <p className="mt-3 text-xs text-muted-foreground">rewriting the selection</p>}

        {/* The original stays in the document until one of these is pressed. */}
        {proposal && !pending && (
          <div className="mt-3 border-t pt-3">
            <p className="text-sm leading-relaxed text-card-foreground">{proposal}</p>
            <div className="mt-3 flex gap-2">
              <Button size="sm" className="h-7 text-xs" onClick={() => onAccept(proposal)}>accept</Button>
              <Button variant="outline" size="sm" className="h-7 text-xs" onClick={onDiscard}>discard</Button>
              <Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>try again</Button>
            </div>
          </div>
        )}
      </PopoverContent>
    </Popover>
  );
}

demo.tsxHow it is called: the intro selected, shorter already proposed. Accept swaps that paragraph and nothing around it; undo is one step.

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<string, string> = {
  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<typeof text | null>(null);
  const [open, setOpen] = useState(true);
  const [proposal, setProposal] = useState<string | null>(REWRITES.shorter);
  const [last, setLast] = useState<Request>({ verb: "shorter" });

  const request = (r: Request) => {
    setLast(r);
    setProposal("verb" in r ? REWRITES[r.verb] : REWRITES.shorter);
  };

  return (
    <div className="min-h-[300px] rounded-lg border bg-card p-4 text-sm leading-relaxed text-card-foreground">
      <div>
        {text.before}
        <InlineAssist
          selection={text.selected}
          open={open}
          onOpenChange={setOpen}
          proposal={proposal}
          onRequest={request}
          onAccept={(next) => {
            setPrevious(text);
            setText({ ...text, selected: next });
            setProposal(null);
            setOpen(false);
          }}
          onDiscard={() => { setProposal(null); setOpen(false); }}
          onRetry={() => request(last)}
        />
        {text.after}
      </div>
      {previous && (
        <button type="button" className="mt-3 text-xs text-muted-foreground underline" onClick={() => { setText(previous); setPrevious(null); }}>
          undo
        </button>
      )}
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.