PixelDosa UI

Diff Accept

Review an AI-proposed text change as an inline diff, resolved hunk by hunk — not all-or-nothing — with a conflict guard if the base text has drifted.

Click "Rewrite with AI", accept or reject each change individually, then Apply. Edit the bio textarea after a suggestion appears (before applying) to see the conflict guard.

Installation

bash
npx shadcn@latest add @pixeldosa/diff-accept

Uses diffWords from the diff package for word-level diffing — not a line-level diff, since this targets prose fields, not code. Unlike smart-field/ghost-input, this component does not call an AI provider itself; the consumer supplies an already-generated proposedValue (which may itself come from a smart-field-style fetch). baseValue must be exactly the text the proposal was generated against, not the current value, or the conflict guard will trigger on every render.

Usage

"use client";

import * as React from "react";
import { DiffAccept } from "@/components/ui/diff-accept";

export function BioField() {
  const [value, setValue] = React.useState(bio);
  const [proposal, setProposal] = React.useState<{ base: string; proposed: string } | null>(null);

  async function rewrite() {
    const proposed = await callYourModel(value);
    setProposal({ base: value, proposed });
  }

  return (
    <>
      <textarea value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={rewrite}>Rewrite with AI</button>
      {proposal ? (
        <DiffAccept
          baseValue={proposal.base}
          value={value}
          proposedValue={proposal.proposed}
          onApply={(merged) => setValue(merged)}
          onRegenerate={rewrite}
        />
      ) : null}
    </>
  );
}

Unlike Smart Field or Ghost Input, this component doesn't call an AI provider itself — it takes an already-generated proposedValue (which may come from your own call, or a Smart Field-style fetchProposal) and handles reviewing it.

Per-hunk review, not all-or-nothing

Each changed region renders inline with its own Accept/Reject controls. Unresolved hunks default to the original text when you click Apply — a change only lands in the result if it was explicitly accepted.

Conflict guard

baseValue must be exactly the text the proposal was generated against. If value (the current live value) no longer matches it — the user edited the text elsewhere while the proposal was in flight — the diff is not shown. A conflict banner explains why and offers onRegenerate instead of risking a stale merge.

Features

  • Hunk-by-hunk review, not all-or-nothing — each changed region has its own Accept/Reject, and unresolved hunks fall back to the original text on Apply. A change only lands if it was explicitly accepted.
  • A conflict guard that refuses to guess. If the live value has drifted from the baseValue the proposal was generated against, the diff is withheld entirely and the user is offered a regenerate instead of a stale merge.
  • Decoration, not colour, for additions and removals — strikethrough and underline read correctly for colourblind users, and the token set has no red/green pair to spend here anyway (DESIGN.md §3.2).
  • A real undo window after Apply, so accepting is recoverable rather than final.
  • Labels that survive being read out of context: each control names the actual changed text, e.g. Accept: "design engineer", not a bare "Accept".
  • Provider-agnostic — it reviews a proposedValue you already have, and never calls a model itself.

Examples

Composed after a Smart Field-style proposal, where the model call lives in your code and this component only handles the review step:

const [proposal, setProposal] = React.useState<{ base: string; proposed: string } | null>(null);

async function rewrite() {
  const base = value;
  setProposal({ base, proposed: await callYourModel(base) });
}

{proposal ? (
  <DiffAccept
    baseValue={proposal.base}
    value={value}
    proposedValue={proposal.proposed}
    onApply={(merged) => {
      setValue(merged);
      setProposal(null);
    }}
    onRegenerate={rewrite}
  />
) : null}

Lengthening the undo window for a higher-stakes field, where a mistaken Apply is costlier to notice late:

<DiffAccept
  baseValue={base}
  value={value}
  proposedValue={proposed}
  onApply={setValue}
  undoWindowMs={15000}
/>

Omitting onRegenerate is valid — the conflict banner then explains the drift without offering a retry, which is the right call when regenerating would cost a paid call the user didn't ask for.

Props

PropTypeDefaultDescription
baseValuestring—Required. The text the proposal was generated against.
valuestring—Required. The current live value, compared to baseValue for conflicts.
proposedValuestring—Required. The AI's proposed replacement text.
onApply(mergedValue: string) => void—Required. Fires with the resolved merge when Apply is clicked.
onRegenerate() => void—Shown as a button on conflict.
undoWindowMsnumber6000How long Undo stays available after Apply.

Accessibility

  • Each hunk's Accept/Reject buttons carry a label naming the actual changed text (e.g. Accept: "design engineer"), not just "Accept" — meaningful out of context.
  • Additions and removals are distinguished by text decoration (strikethrough / underline + tint), not colour alone — the token set has no red/green pair to spend on this anyway, and decoration reads correctly for colourblind users.
  • Apply/Undo state changes are announced via aria-live="polite".
  • Every control is a real, keyboard-reachable <button>.
  • Under prefers-reduced-motion, all colour/background transitions drop to instant state changes; the underlying decoration stays fully legible without animation.

When not to use

A single atomic value with no internal structure to partially accept — a company name, a date, a category — is Smart Field, not this. Diff Accept is for text with multiple independently-reviewable regions.

why it's built this way

Engineering Notes

Uses diff (jsdiff) for the actual word-level diff computation rather than hand-rolling a diff algorithm — the same relationship command-menu has with cmdk: a solved problem with real edge cases (Unicode, whitespace runs, word-boundary detection) not worth re-solving here. Hunks are grouped from the raw diff parts by merging consecutive added/removed runs into one reviewable unit, so a 'replace X with Y' shows as a single hunk with both the removal and the replacement, not two separate uncoordinated changes. Unresolved (pending) hunks default to the original text when merging, never the proposed one — an AI change only lands in the final result if a hunk is explicitly accepted, matching the system-wide 'never silently commit' rule. The status model is a three-state machine (reviewing/applied/done) rather than a single boolean: collapsing 'applied' and 'done' into one flag caused a real bug during development, where the conflict check (value !== baseValue, which becomes true immediately after Apply changes the live value) would incorrectly override the Undo affordance and show a conflict banner instead — the fix was ordering status checks so 'applied'/'done' short-circuit before the conflict check ever runs, since conflict is only a meaningful question while still reviewing.

how it moves, and when it doesn't

Motion Notes

The system has no 'success'/'added' hue — destructive is the only token keeping real chroma — so additions and removals are distinguished primarily by text decoration (strikethrough for removed, underline plus a bg-accent/40 tint for added), not a red/green colour scheme, with destructive reserved for the removed text only since deletion is the one case that's legitimately danger-adjacent. This is also more accessible than colour alone, not just a token-budget workaround. Colour and background transitions on hunk resolution use --pd-duration-instant/--pd-ease-standard, matching Button's press-feedback timing, since resolving a hunk is an immediate state confirmation. Apply's brief settle state mirrors Smart Field's accept feedback for the same component family consistency. Under prefers-reduced-motion, all colour and background transitions drop via motion-reduce:transition-none, leaving the underlying decoration (strikethrough/underline) and resolved text fully legible without animation.