Skip to content
KONIGI

AI Assistants / In-product assistance / Inline assist

4 of 5

Inline assist

The viewer wants one paragraph changed and does not want to leave the document to ask for it.

Updated September 12, 2026

Problem

One paragraph in the middle of a draft is too long. Asking a chat assistant to fix it means copying the paragraph out, describing which document it came from, pasting the reply back and re-checking the formatting. The work of asking exceeds the work of fixing it by hand.

Solution

Make the selection the object of the request. The viewer highlights the text, invokes the assistant, and picks a verb. Nothing has to be described, because the thing being acted on is already identified by the cursor. Nothing else gives an in-product assistant so clear an advantage over a general one, and it’s worth building the whole interaction around.

Invocation wants two paths: a floating control that appears on selection, for discovery, and a keyboard shortcut, for the people who will use it forty times a day. The verb list should be short and concrete. Notion’s is a good reference shape, offering fixes like grammar, length, and tone against a selection, with a free-text prompt underneath for anything not on the list.

The output decision is where implementations diverge and where most of them go wrong. Replacing the selection immediately is the cheapest to build and destroys the original at the moment the viewer most wants to compare. Presenting the result as a proposal, with accept, discard and try-again, keeps the original until a decision is made. Notion’s documented flow is exactly this triad, and it’s the correct one: the viewer sees both states, picks, and the document changes once.

Scope discipline is the other half. HAX guideline 10 asks systems to scope their services when uncertain, and the inline case is the clearest instance of that rule anywhere. An assist asked to fix the grammar in one sentence that returns a rewritten paragraph has broken the contract, and once it’s done that twice nobody will trust it on a long selection again. Whatever was highlighted is the whole permitted blast radius.

Latency matters more here than in chat. The viewer is mid-sentence with a held thought. A chat answer can take fifteen seconds because the viewer has handed the task over; an inline rewrite can’t, because they’re waiting inside their own work. Where the model is slow, a smaller and faster one for the common verbs beats a better answer that breaks the writing rhythm.

Use when

The assistant lives inside an editor and the viewer is working on text they own.

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

The selection bounds the work, which is the strength and the limit: a change that needs to touch the paragraph before and the heading above can’t be expressed. Accept-and-discard is safer than replacement and adds a decision to every single invocation, which is friction paid every time to prevent an occasional loss. A floating toolbar on selection collides with the editor’s own formatting controls and with the browser’s native selection menu. Undo has to collapse the whole operation into one step. An assist that takes six presses to reverse teaches people to stop trying it.

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 is how the proposal should be presented when the change is large. Ghost text is the same help offered before a selection exists. Assistant sidebar is where a request goes once it needs a conversation. AI entry point is the affordance that makes this discoverable. Prompt starters are what fills the free-text field before the viewer knows what to type.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.