Scrim UI

Inline Correction UI

Fix the answer where it is wrong, in place — the edit becomes training data, so the component keeps the original alongside it rather than overwriting it.

feedbackcorrectionediteval
npx shadcn@latest add https://scrimui.dev/r/inline-correction.json
Agent promptClaude Code · Cursor · any agent
Add the Inline Correction UI component from the Scrim UI registry to this project and use it in the configuration described below.

Inline Correction UI — Fix the answer where it is wrong, in place — the edit becomes training data, so the component keeps the original alongside it rather than overwriting it.

## 1. Install

```bash
npx shadcn@latest add https://scrimui.dev/r/inline-correction.json
```

This writes a single file to `components/ui/inline-correction.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/inline-correction to the same path by hand; nothing in the file depends on shadcn.

## 2. Use it

```tsx
import { InlineCorrection } from "@/components/ui/inline-correction";

<InlineCorrection
  text="Chunk overlap should be set to 50% of the chunk size. This guarantees every sentence appears in two chunks and is always retrievable."
  onSubmit={() => {}}
/>
```

## 3. The configuration that matters

This is the component's "Uncorrected" state — The resting state. Fix this appears on hover and on focus — hover-only would put the affordance out of reach of a keyboard and off a phone entirely.

Every prop is at its default value. Keep the call site minimal — do not write out default values.

## 4. Wire the handlers

`onSubmit` is a stub in the snippet above (`() => {}`). Connect it to this project's own state and data layer instead of shipping the empty function.

Reference: https://scrimui.dev/components/inline-correction

Follows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.

Chunk overlap should be set to 50% of the chunk size. This guarantees every sentence appears in two chunks and is always retrievable.

Presets

Props

Also takes onSubmit.

Component source

Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.

inline-correction.tsx
"use client";

import * as React from "react";

/**
 * Fix the answer where it is wrong, in place.
 *
 * A thumbs-down says something is wrong. A correction says *what the right
 * answer was*, which is worth roughly an order of magnitude more and is
 * almost never collected, because the obvious implementation destroys the
 * thing it was trying to capture.
 *
 * **Never overwrite the original.** The value in a correction is the *pair* —
 * what the model said and what a human replaced it with. An editor that swaps
 * the text in place has collected half a training example and thrown away the
 * half that identifies the failure. So `text` stays, `correction` is a second
 * field, and the reader can flip between them after saving.
 *
 * **The edit is not the message.** Correcting an answer must not send a new
 * turn — that is a different act with a different meaning, and conflating
 * them means every correction also drags the conversation forward. This
 * component emits a correction and nothing else; what the conversation does
 * next is the caller's decision.
 *
 * **Escape must not lose the work.** A textarea that discards on Escape is a
 * textarea people learn not to use. Escape asks; only an empty draft closes
 * silently.
 *
 * What is deliberately not here: rich text. A correction is a claim about
 * facts, and a formatting toolbar invites edits that are about taste, which
 * is noise in the very dataset this exists to build.
 */

export type InlineCorrectionProps = {
  /** What the model said. Never mutated. */
  text: string;
  /** The accepted correction, once there is one. */
  correction?: string;
  /** Who corrected it, for a shared thread. */
  correctedBy?: string;
  onSubmit?: (corrected: string) => void;
  /** Withdraw a correction. Should delete it server-side, not hide it. */
  onRevert?: () => void;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Icons                                                               */
/* ------------------------------------------------------------------ */

function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
    </svg>
  );
}

function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* InlineCorrection                                                    */
/* ------------------------------------------------------------------ */

export function InlineCorrection({
  text,
  correction,
  correctedBy,
  onSubmit,
  onRevert,
  className = "",
}: InlineCorrectionProps) {
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(correction ?? text);
  const [showOriginal, setShowOriginal] = React.useState(false);
  const areaRef = React.useRef<HTMLTextAreaElement>(null);

  React.useEffect(() => {
    if (!editing) return;
    const el = areaRef.current;
    if (!el) return;
    el.focus();
    /* Caret at the end rather than selecting everything: a correction is
       usually a small edit to a long paragraph, and select-all means the
       first keystroke deletes the answer they were trying to fix. */
    el.setSelectionRange(el.value.length, el.value.length);
  }, [editing]);

  function open() {
    setDraft(correction ?? text);
    setEditing(true);
  }

  function save() {
    const next = draft.trim();
    if (next === "" || next === (correction ?? text)) {
      setEditing(false);
      return;
    }
    onSubmit?.(next);
    setEditing(false);
  }

  const shown = correction !== undefined && !showOriginal ? correction : text;

  if (editing) {
    return (
      <div className={className}>
        <textarea
          ref={areaRef}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
              e.preventDefault();
              save();
            }
            if (e.key === "Escape") {
              e.preventDefault();
              /* Only a draft that has not moved closes silently. Anything
                 else asks — a textarea that discards on Escape is one people
                 learn not to trust with anything long. */
              if (draft === (correction ?? text)) setEditing(false);
              else if (confirm("Discard this correction?")) setEditing(false);
            }
          }}
          rows={Math.min(12, Math.max(3, draft.split("\n").length + 1))}
          className="w-full resize-y rounded-xl border border-zinc-300 bg-white px-3 py-2.5 text-[15px] leading-7 text-zinc-900 outline-none focus:border-zinc-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400"
        />
        <div className="mt-2 flex items-center gap-2">
          <button
            type="button"
            onClick={save}
            className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 text-xs font-medium text-white transition-opacity hover:opacity-90 dark:bg-zinc-100 dark:text-zinc-900"
          >
            <CheckIcon />
            Save correction
          </button>
          <button
            type="button"
            onClick={() => setEditing(false)}
            className="inline-flex h-8 items-center rounded-lg px-3 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
          >
            Cancel
          </button>
          <span className="ml-auto text-[11px] text-zinc-400 dark:text-zinc-500">
            This does not send a message
          </span>
        </div>
      </div>
    );
  }

  return (
    <div className={`group ${className}`}>
      <p
        className={`whitespace-pre-wrap text-[15px] leading-7 ${
          correction !== undefined && showOriginal
            ? "text-zinc-400 line-through decoration-zinc-300 dark:text-zinc-500 dark:decoration-zinc-600"
            : "text-zinc-700 dark:text-zinc-200"
        }`}
      >
        {shown}
      </p>

      <div className="mt-1.5 flex flex-wrap items-center gap-2">
        {correction === undefined ? (
          <button
            type="button"
            onClick={open}
            /* Visible on focus as well as hover: a hover-only edit affordance
               is unreachable by keyboard and invisible on touch. */
            className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[11px] font-medium text-zinc-400 opacity-0 transition-opacity hover:bg-zinc-100 hover:text-zinc-700 focus-visible:opacity-100 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
          >
            <PencilIcon />
            Fix this
          </button>
        ) : (
          <>
            <span className="inline-flex items-center gap-1.5 rounded-lg bg-amber-100 px-2 py-1 text-[11px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
              <PencilIcon />
              Corrected{correctedBy ? ` by ${correctedBy}` : ""}
            </span>
            <button
              type="button"
              onClick={() => setShowOriginal((v) => !v)}
              className="inline-flex h-7 items-center rounded-lg px-2 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
            >
              {showOriginal ? "Show correction" : "Show what the model said"}
            </button>
            <button
              type="button"
              onClick={open}
              className="inline-flex h-7 items-center rounded-lg px-2 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
            >
              Edit again
            </button>
            {onRevert && (
              <button
                type="button"
                onClick={onRevert}
                className="inline-flex h-7 items-center rounded-lg px-2 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
              >
                Withdraw
              </button>
            )}
          </>
        )}
      </div>
    </div>
  );
}

When to use it

  • Keep the original and the correction as two fields. The pair is the training example — what the model said and what a human replaced it with — and either half alone is close to worthless.
  • Emit a correction, not a message. Sending a new turn is a different act with a different meaning, and conflating them means every correction drags the conversation forward.
  • Store the model version and the message id with the correction, or you cannot tell later whether the next version fixed it.
  • Put the caret at the end rather than selecting the whole answer. A correction is usually a small edit to a long paragraph, and select-all means the first keystroke destroys it.
  • Make Withdraw a delete. A correction that is hidden but still in the table is a label you will train on and cannot explain.
  • Show the edit affordance on focus as well as hover, or keyboard and touch users never find it.

What breaks in production

  • Editing the answer in place. It looks like the tidiest possible implementation and it throws away the only part that identifies the failure.
  • Sending the correction as the next user message. The conversation moves, the model responds to it, and what you wanted to record was a label rather than a turn.
  • Discarding the draft on Escape. It happens once, to a long correction, and that person never uses the feature again.
  • Adding a formatting toolbar. A correction is a claim about facts; rich text invites edits about taste, which is noise in the dataset this exists to build.
  • Accepting a correction identical to the original. It is a no-op the reader thinks worked, and a row of them quietly dilutes every metric computed over the table.

Related Components