Review AI agent edits before they land

An AI cleanup agent proposes edits to a customer table instead of writing them. Each changed cell shows the proposed value with the old one struck through beside it; the review panel groups the proposals by what they do (trim spaces, title case, one phone format, a country mapping table, region fills with the unsure ones first) and you accept or reject a group at a time. One Ctrl+Z undoes a decision, a cell you edit first is never overwritten, and an import that disagrees with the agent shows up as a conflict. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Misc). See the SvGrid documentation for the full API.

About this example

AI and import edits to a Svelte 5 data grid held as proposals: each changed cell shows the proposed value with the old one struck through beside it, a review panel groups the proposals by what they change, and a person accepts or rejects a group at a time, with one Ctrl+Z per decision.

A customer table with the dirt a CRM export carries. Run the cleanup agent and its edits arrive as proposals, not writes: each changed cell shows the proposed value with the old one struck through beside it, and the panel groups them by what they do.

Trim spaces, Change to Title Case, Reformat Phone, Divide Revenue by 100 rule groups: every member follows the rule Country: 5 values -> ... a mapping table, one decision Fill empty Region split by value; the low-confidence fills (two of them wrong) lead the sample

Accept a group and one setRows call writes it; Ctrl+Z in the grid puts it back. Edit a cell that has a proposal, then accept: the proposal is marked stale and your value stays. Import the CRM update and one cell gets a second, different proposal: a conflict, decided in the panel.

The agent here is a few fixed rules standing in for a model, so the demo runs without a key. With one, reviewSmartFill / reviewClassify feed the same review from the AI helpers.

Imports, features and API used

Imports: @svgrid/grid, @svgrid/enterprise/review

Columns: company (Company), contact (Contact), phone (Phone), country (Country), region (Region), revenue (Revenue)

Frequently asked questions

Does an accepted proposal overwrite a cell someone edited meanwhile?

No. Before writing, each proposal's cell is compared with the value the proposal saw, once before your onCommit runs and again after it. A cell that changed, by typing, a paste or a fill, is marked stale and left as it is.

How are the groups decided?

By fixed rules, with no model call: trimmed spaces, a case change, the same characters in a new format, one numeric factor, the same mapping repeated, empty cells filled with one value, many values set to one, and the rest per column. The first four prove their rule for every member, so they sort last; groups that need judgment sort first.

Can the AI helpers feed it?

Yes. reviewSmartFill and reviewClassify run aiSmartFill and aiClassify and add their predictions as proposals, keyed by row id, with the model's confidence and rationale shown in the panel.

Which license does it need?

Change review is part of @svgrid/enterprise. Without a key it runs with a watermark, so the demo can be evaluated as is. It adds nothing to the free @svgrid/grid bundle.

Related documentation

Related articles

Source code (702-change-review.svelte)

<script lang="ts">
  /**
   * 702. Change review: an agent's edits, accepted by group
   * --------------------------------------------------------
   * A customer table with the dirt a CRM export carries. Run the cleanup
   * agent and its edits arrive as proposals, not writes: each changed cell
   * shows the proposed value with the old one struck through beside it, and
   * the panel groups them by what they do.
   *
   *   Trim spaces, Change to Title Case, Reformat Phone,
   *   Divide Revenue by 100      rule groups: every member follows the rule
   *   Country: 5 values -> ...   a mapping table, one decision
   *   Fill empty Region          split by value; the low-confidence fills
   *                              (two of them wrong) lead the sample
   *
   * Accept a group and one setRows call writes it; Ctrl+Z in the grid puts it
   * back. Edit a cell that has a proposal, then accept: the proposal is
   * marked stale and your value stays. Import the CRM update and one cell gets
   * a second, different proposal: a conflict, decided in the panel.
   *
   * The agent here is a few fixed rules standing in for a model, so the demo
   * runs without a key. With one, reviewSmartFill / reviewClassify feed the
   * same review from the AI helpers.
   */
  import { SvButton, SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'
  import { createReview, fromRows, SvReviewPanel, type ProposalInput } from '@svgrid/enterprise/review'

  type Customer = {
    id: string
    company: string
    contact: string
    phone: string
    country: string
    region: string
    revenue: number
  }

  const COMPANIES = ['Acme Corp', 'Globex', 'Initech', 'Umbrella Labs', 'Hooli', 'Vandelay Imports', 'Stark Industries',
    'Wayne Holdings', 'Tyrell Systems', 'Cyberdyne', 'Aperture Science', 'Soylent Foods', 'Oscorp', 'Wonka Partners',
    'Gringotts', 'Monarch Group', 'Pied Piper', 'Dunder Paper', 'Prestige Worldwide', 'Massive Dynamic']
  const PEOPLE = ['maria silva', 'wei chen', 'aisha khan', 'carlos ruiz', 'emma berg', 'liam novak', 'sofia rossi',
    'noah okafor', 'yuki tanaka', 'omar haddad']
  const COUNTRIES = ['United States', 'usa', 'Germany', 'U.S.', 'Japan', 'US', 'United Kingdom', 'United States of America', 'Brazil', 'U.S.A.']
  const REGION_OF: Record<string, string> = { 'United States': 'AMER', Brazil: 'AMER', Germany: 'EMEA', 'United Kingdom': 'EMEA', Japan: 'APAC' }
  const US = new Set(['usa', 'U.S.', 'US', 'United States of America', 'U.S.A.'])

  const seed = (): Customer[] =>
    COMPANIES.map((name, i) => {
      const country = COUNTRIES[i % COUNTRIES.length]
      const a = 200 + ((i * 37) % 700)
      const b = 100 + ((i * 53) % 800)
      const c = 1000 + ((i * 271) % 9000)
      return {
        id: `c${i + 1}`,
        company: i % 3 === 0 ? `${name}  ` : i % 7 === 1 ? `  ${name}` : name,
        contact: i % 2 === 0 ? PEOPLE[i % PEOPLE.length].toUpperCase() : titleCase(PEOPLE[i % PEOPLE.length]),
        phone: i % 4 === 3 ? `${a}-${b}-${c}` : `(${a}) ${b}-${c}`,
        country,
        region: i % 3 === 1 ? '' : REGION_OF[US.has(country) ? 'United States' : country] ?? '',
        // Six rows came in from the old billing system, in cents.
        revenue: i % 3 === 2 ? (40 + i * 13) * 1000 * 100 : (40 + i * 13) * 1000,
      }
    })

  function titleCase(s: string) {
    return s.replace(/\b\p{Ll}/gu, (ch) => ch.toUpperCase())
  }

  let rows = $state(seed())
  let api = $state<SvGridApi<any, Customer> | null>(null)
  const review = createReview<Customer>({ api: () => api, setRows: (next) => (rows = next) })

  const columns: GridColumns<Customer> = [
    { field: 'company', header: 'Company', width: 190, editable: true },
    { field: 'contact', header: 'Contact', width: 150, editable: true },
    { field: 'phone', header: 'Phone', width: 140, editable: true },
    { field: 'country', header: 'Country', width: 170, editable: true },
    { field: 'region', header: 'Region', width: 100, editable: true },
    { field: 'revenue', header: 'Revenue', width: 130, editable: true, editorType: 'number', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
  ]

  /** A few fixed rules standing in for a cleanup model. */
  function runAgent() {
    const proposals: ProposalInput[] = []
    for (const r of rows) {
      const add = (field: keyof Customer, after: unknown, extra: Partial<ProposalInput> = {}) => {
        if (r[field] !== after) proposals.push({ rowId: r.id, field, before: r[field], after, ...extra })
      }
      add('company', r.company.trim())
      add('contact', titleCase(r.contact.toLowerCase()))
      const digits = r.phone.replace(/\D/g, '')
      add('phone', `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`)
      const country = US.has(r.country) ? 'United States' : r.country
      add('country', country)
      if (!r.region) {
        // Two guesses are wrong, and the agent says it is unsure of them.
        // c5 is in Japan and c11 in the United States.
        const guess = r.id === 'c5' ? 'EMEA' : r.id === 'c11' ? 'APAC' : null
        const unsure = guess !== null
        add('region', guess ?? REGION_OF[country], {
          confidence: unsure ? 0.42 : 0.93,
          reason: unsure ? 'guessed from the company name' : `from Country (${country})`,
        })
      }
      if (r.revenue >= 1_000_000) add('revenue', r.revenue / 100, { reason: 'billing export was in cents' })
    }
    review.add({ source: { kind: 'agent', id: 'cleanup', label: 'Cleanup agent' }, proposals })
  }

  /** The CRM sends its own region for one customer, different from the agent's. */
  function runImport() {
    if (!api) return
    const set = fromRows<Customer>([{ id: 'c2', region: 'EMEA' }, { id: 'c8', region: 'EMEA' }], {
      api,
      key: 'id',
      source: { kind: 'import', id: 'crm-update.csv', label: 'crm-update.csv' },
    })
    review.add(set)
  }

  function reset() {
    review.clear()
    rows = seed()
  }

  const pending = $derived(review.pending().length)

  // A phone has no room for both side by side, and the demo stage has a fixed
  // height: show one at a time. The grid stays mounted, so its api does too.
  let narrow = $state(false)
  let view = $state<'grid' | 'review'>('grid')
  $effect(() => {
    const mq = window.matchMedia('(max-width: 860px)')
    const sync = () => (narrow = mq.matches)
    sync()
    mq.addEventListener('change', sync)
    return () => mq.removeEventListener('change', sync)
  })
</script>

<div class="cr-demo">
  <div class="cr-bar">
    <SvButton size="sm" onclick={runAgent}>Run cleanup agent</SvButton>
    <SvButton size="sm" variant="outline" onclick={runImport}>Import crm-update.csv</SvButton>
    <SvButton size="sm" variant="ghost" onclick={reset}>Reset</SvButton>
    <span class="cr-note">{pending ? `${pending} proposed, nothing written yet` : 'The data is as loaded'}</span>
  </div>
  {#if narrow}
    <div class="cr-bar" role="group" aria-label="Show">
      <SvButton size="sm" variant={view === 'grid' ? 'secondary' : 'ghost'} onclick={() => (view = 'grid')}>Grid</SvButton>
      <SvButton size="sm" variant={view === 'review' ? 'secondary' : 'ghost'} onclick={() => (view = 'review')}>Review ({pending})</SvButton>
    </div>
  {/if}
  <div class="cr-split">
    <div class="cr-grid" class:cr-hidden={narrow && view === 'review'}>
      <SvGrid
        data={rows}
        columns={review.columns(columns)}
        getRowId={(r) => r.id}
        conditionalFormats={review.formats}
        editable
        containerHeight={460}
        onApiReady={(a) => (api = a)}
      />
    </div>
    <div class="cr-panel" class:cr-hidden={narrow && view === 'grid'}>
      <SvReviewPanel {review} title="Review the agent's changes" />
    </div>
  </div>
</div>

<style>
  .cr-demo {
    display: flex;
    flex-direction: column;
    gap: 10px;
    font-family: var(--sg-font, inherit);
    color: var(--sg-fg);
  }
  .cr-bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
  }
  .cr-note {
    color: var(--sg-muted);
    font-size: 13px;
  }
  .cr-split {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 340px;
    gap: 12px;
    align-items: start;
  }
  .cr-grid,
  .cr-panel {
    min-width: 0;
  }
  /* The stage has a fixed height: the panel scrolls within the grid's. */
  .cr-panel {
    max-height: 460px;
    overflow: auto;
  }
  .cr-hidden {
    display: none;
  }
  @media (max-width: 860px) {
    .cr-split {
      grid-template-columns: minmax(0, 1fr);
    }
  }
</style>

View this example on GitHub

More Misc examples

  • Bulk-action bar - selectionBar floats a bar over the grid while rows are selected - the count, your actions, an overflow menu, and Clear - the pattern an issue tracker uses for bulk edit. position pins it to the bottom (default) or top edge. Actions receive the selected rows in display order, and hidden / disabled re-check against the live selection so buttons appear and grey out as the set changes.
  • Overdue filters, cell labels and a row count - Enterprise filter and sort additions on a review queue. Due's filter menu offers Today, Before today, Next 7 days and the rest, with nothing to type; Status is stored as codes and its checklist reads Approved and Failed, as the cells do; the status bar counts rows, "6 of 24 rows" under a filter; and reviews with no due date stay last whichever way Due sorts.
  • Staged / batch editing - Edits buffer into a draft; user reviews every change in a side panel, then commits the batch (one server roundtrip) or reverts back to originals.