Change review - Enterprise

An AI helper fills 900 empty cells, an agent cleans up a CRM export, an import updates 2,000 rows. None of those writes was typed by the person who owns the data, and none of them should land before someone has looked. Change review holds such writes as proposals, apart from the data, groups them by what they do, and lets a person accept or reject a group at a time. Only accepted values are written, and each decision is one Ctrl+Z.

import { createReview, reviewSmartFill, SvReviewPanel } from '@svgrid/enterprise/review'

Open the live example: Review AI agent edits before they land (Misc)

In the demo, Run cleanup agent proposes fixes to a customer table. Nothing is written until you accept a group in the panel. Edit a cell that has a proposal before accepting it and the proposal is marked stale. Import crm-update.csv proposes a different region for two customers the agent also filled, and the panel asks which one to keep.

A grid with a review

<script lang="ts">
  import { SvGrid, type SvGridApi } from '@svgrid/grid'
  import { createReview, SvReviewPanel } from '@svgrid/enterprise/review'

  let rows = $state(customers)
  let api = $state<SvGridApi<any, Customer> | null>(null)

  const review = createReview<Customer>({
    api: () => api,
    setRows: (next) => (rows = next),
    onCommit: async (accepted) => {
      await fetch('/api/customers/bulk', { method: 'PATCH', body: JSON.stringify(accepted) })
    },
  })
</script>

<div class="layout">
  <SvGrid
    data={rows}
    columns={review.columns(columns)}
    getRowId={(r) => r.id}
    conditionalFormats={review.formats}
    onApiReady={(a) => (api = a)}
  />
  <SvReviewPanel {review} />
</div>

Three things are required:

Adding proposals

Anything can add a set of proposals. Each one names the row, the field, the value it saw (before) and the value it proposes (after):

review.add({
  source: { kind: 'agent', id: 'crm-cleanup', label: 'Cleanup agent' },
  proposals: [
    { rowId: 'c-104', field: 'company', before: 'Acme Inc.', after: 'Acme Inc' },
    { rowId: 'c-221', field: 'phone', before: '(555) 123-4567', after: '555-123-4567', reason: 'one phone format' },
  ],
})

confidence (0 to 1) and reason are optional and show in the panel. Proposals whose after equals before are skipped. A newer proposal from the same source for the same cell replaces the older one. Two sources proposing different values for one cell is a conflict: neither can be accepted by a group action until a person keeps one.

From the AI helpers

The AI helpers in @svgrid/grid return predictions keyed by data index. reviewSmartFill and reviewClassify snapshot the data before the call, resolve each index to its row id, drop indices the helper was not asked about, and add the result:

const result = await reviewSmartFill(review, api, {
  field: 'region',
  examples: [{ input: { country: 'Germany' }, output: 'EMEA' }],
})
result.dropped   // predictions that pointed at rows outside the request

fromSmartFill(result, snapshot, { api }) and fromClassify do the same for a result you already have; pass the snapshot you took before calling the helper.

From an import or a sync

fromRows diffs incoming rows against the grid's data by a key field and proposes one change per field that differs:

const set = fromRows(imported.rows, {
  api,
  key: 'id',
  source: { kind: 'import', id: file.name, label: file.name },
})
review.add(set)
set.unmatched   // incoming rows with no match; adding rows is not proposed yet

How proposals are grouped

The panel never shows 2,000 cells in a list. review.groups() puts every pending proposal in one group by the change it makes. The first four kinds are rule groups: every member is proven to follow the rule. The rest are judgment groups, where a person has to look.

Kind Rule Label in the panel
whitespace after is before trimmed or with spaces collapsed Trim spaces in Company
case same letters, different case Change Email to lower case
format same characters apart from punctuation, the same unambiguous date, or text that parses to the number Reformat Phone as 999-999-9999
scale after / before is one number, 5 or more members Divide Revenue by 100
same-mapping several before values each mapped to one after at least twice Country: 6 values → United States
fill-empty blank before, one group per proposed value Fill empty Region with EMEA
constant-target many different before values, one after Set Status to Closed
unpatterned everything else, per field Notes: no shared pattern

A few cases are kept out of the rule groups on purpose. "3.5" to "35" keeps every digit but changes the number, so it is not a format change. 03/05/2024 could be March or May, so a reformat of it is never proven. A rule group proves that every member follows its rule, not that the rule is right: an agent that divides by 10 where it should divide by 100 shows up as its own "Divide Revenue by 10" group, labelled for what it does.

The panel lists the risky groups first: unpatterned, then one target from many values, then groups with low confidence, then the rest by size. Each group carries a sample to check: every member below 60% confidence (up to ten), then members spread across the group. An unpatterned group lists every member. same-mapping and constant-target groups show which before values they map, so "everything became Canada" is visible at a glance.

What the grid shows

review.formats tints every cell with a pending proposal and draws a dot before its value, so the mark is never colour alone. Pass it as conditionalFormats, concatenated with your own formats if you have them. Theme the tint with --sg-review-pending-bg. A cell two sources disagree on gets a red wash and a "!" instead (--sg-review-conflict-bg), so the grid shows where the panel's conflicts are.

review.columns(columns) draws the proposal inside the cell: the proposed value, then the current value struck through. It does this by wrapping the column's cell, and only for columns where that leaves the rest of the column as it was: columns with a cell of their own, and plain text columns. Checkbox, list, chips and boolean columns, and field types with a cell of their own, keep their cell as it is and show the proposed value after it ("→ Closed").

Deciding

const outcome = await review.acceptGroup(group.key, group.ids)
// { accepted: [...], rejected: [], stale: [...], skipped: [...], error? }

On accept, for each proposal:

  1. An open cell editor is committed first, so a value someone just typed wins. Under fullRowEditing, the row being edited is left pending instead.
  2. If the cell no longer holds before, the proposal becomes stale and is not written.
  3. onCommit receives the remaining proposals. Throw or reject to keep them pending; nothing is written.
  4. The cells are checked again, because a person could edit one while onCommit was saving.
  5. One setRows call writes every accepted value.
  6. One undo step is recorded. Ctrl+Z in the grid writes the old values back, by row id, so it works with paging and filters on, and returns the proposals to pending. A cell edited after the accept is left as it is.

acceptGroup and rejectGroup take the ids that were shown, so a proposal that arrived after the panel rendered is never decided unseen.

Server row model

With the server row model there is no app array to replace. Pass the controller instead of setRows; onCommit is then required:

const review = createReview({
  api: () => api,
  server: controller,
  onCommit: (accepted) => saveToServer(accepted),
})

Accept saves through onCommit and refreshes the controller. Undo saves the inverse (before and after swapped) and refreshes again. The backend decides staleness there.

SvReviewPanel

Prop Type Default
review Review required What createReview returned.
title string 'Review changes' The panel heading.
confirmOver number 50 Ask before deciding more than this many at once.
onDecide (outcome) => void Called after each decision.

Accept rule groups accepts every rule group at once (trims, case changes, reformats, one numeric factor), the changes that are proven by their rule, and leaves the groups that need a person. It is the panel's primary button, and rule groups are the only ones marked, with a "Rule" badge. Accept all is the primary button only when every group left is a rule group: deciding everything unseen is what a review is there to prevent.

A line in an open group names its row, then the change, then the reason and confidence the source gave. The name is the row's first visible text column (the company, say), and a line leaves it out when that column is the one being changed, since the old value already names the row. Set rowLabel on createReview to choose it yourself:

const review = createReview<Customer>({
  api: () => api,
  setRows: (next) => (rows = next),
  rowLabel: (row) => `${row.company} (${row.city})`,
})

A click on a line shows that row in the grid, and the row lights up for a moment so the eye finds it. A value is quoted only where the quotes say something: around spaces at its ends (shown as dots), or when text reads as a number or a boolean.

Each line of an open group has its own accept and reject, for a group that is mostly right: reject the two wrong guesses, then accept the group. A judgment group lists its sample first; Show all lists every change in it, up to 200. A decision can move what is left of a group under another heading (two "EMEA" fills less one is a single fill, pooled with the others), and the open group follows its remaining changes rather than closing.

It is a plain panel, not a drawer, so the grid stays usable beside it. Inside the group list, J and K move between groups, Enter opens one, A accepts it and R rejects it. "Show in grid" goes to the group's next row without moving the active cell: with client paging it turns to the row's page first (api.getRowPosition), then scrolls to it. Rows a filter hides are counted, not shown. With grouping, where pages count groups, it stays on the current page.

Only rows with changes narrows the grid to the rows with a pending or conflicting proposal, on top of whatever the user filtered: it adds one condition to the advanced filter with AND, and switching it off takes that condition back out and leaves the rest. Rows leave the view as their proposals are decided, like an inbox. From code: review.showPendingOnly(true), and review.pendingOnly tells whether it is on. It needs the advanced filter engine, which it turns on itself.

Agents

createGridTools gives an AI agent four tools over the grid's data. None of them writes the data: the agent's edits arrive in the review like any other proposals, and a person decides.

Open the live example: Agent review desk (App Templates)

In the demo a scripted CRM agent calls these tools step by step; each step shows the tool call it made. The Log tab lists every decision as onDecision reports it.

Tool What it does
grid_describe The columns (field, header, type), the row count, and which fields the agent may edit.
grid_query_rows Rows matching where conditions (equals, contains, blank, gt, ...), with their rowId, paged and capped at maxRows.
grid_propose_edits Adds edits as proposals, with a reason and a confidence. Unknown rows, fields the agent may not edit, and edits that change nothing are skipped and reported.
grid_review_status How many of the agent's proposals are pending, accepted, rejected or stale, so it can learn from the review.
import { createGridTools, registerWebMcpTools } from '@svgrid/enterprise/review'

const tools = createGridTools({
  review,
  api: () => api,
  fields: ['region', 'phone'],   // what the agent may propose edits to
  source: { kind: 'agent', id: 'crm-agent', label: 'CRM agent' },
})

Each tool is { name, description, inputSchema, execute }: hand them to the tool loop of your LLM SDK, or to an MCP server of your own. registerWebMcpTools(tools) registers them with the browser's WebMCP API (document.modelContext), so an agent driving the page can call them. WebMCP is a draft; where a browser does not have it, the call returns { supported: false } and does nothing.

A model's input is not trusted. One grid_propose_edits call carries at most maxEdits edits (500 by default) and a larger one is refused. A value that does not fit its column (text for a number, an object for a text cell, a list for a single value) is skipped and reported back, not proposed.

Audit log and revert

onDecision is called after every accept and reject, and after their undo and redo, with copies of the proposals it moved and who decided. Write these to your own audit log:

createReview({
  api: () => api,
  setRows: (next) => (rows = next),
  user: currentUser.email,
  onDecision: (d) => audit.write({ action: d.action, by: d.by, at: d.at, changes: d.proposals }),
})

review.log() lists the decided proposals in this session, in the order they were decided.

If an undo or redo has to save through onCommit and the save fails, nothing is written and the decision stands. onError(error, 'undo' | 'redo') is called, review.lastError holds it, and the panel says "Undo not saved".

Ctrl+Z covers the last decisions. To take back a whole set later, review.revertSet(setId) adds a new set that proposes each accepted value's reverse. It is reviewed and accepted like any other, so a cell edited after the original accept is not overwritten.

Routing to an approver

review.toJSON({ setIds }) exports sets with their proposals as plain JSON. On the approver's page, review.merge(json) adds them beside what that review already holds. A cell that also has a pending proposal there from another source becomes a conflict. review.load(json) replaces everything instead.

An export can come from another person, another session or an agent, so both check it first. Something that is not an export ({ v: 1, sets: [...] }) throws and leaves the review as it was; a proposal with no id, row, field or a known status is dropped. Both return { dropped }.

A person's own staged edits can take the same route. fromStaged turns a createStagedEditing buffer into proposals. Staged edits have already been written into the grid, so revert the stage first:

const changes = stage.changes()
stage.revert((rowIndex, columnId, value) => api.setCellValue(rowIndex, columnId, value))
review.add(fromStaged(changes, { api, source: { kind: 'user', id: me.id, label: `${me.name} (staged)` } }))
const forApprover = JSON.stringify(review.toJSON({ setIds: [review.sets().at(-1)!.id] }))

At scale

A test in the package runs 100,000 rows and 20,000 proposals through every step and fails if one becomes slow. Measured in jsdom on a development machine (2026-10-24):

Step Time
Add 20,000 proposals 88 ms
Group them 55 ms
Build the tint rules 8 ms
Mark 1,000 cards 24 ms
Accept all 20,000 (one setRows) 140 ms
Undo, then redo 71 ms, 96 ms

Clustering, the stale checks and the writes are each a single pass over the proposals and the rows; nothing compares every proposal with every row.

With SvGrid Base

SvBase hands any prop it does not use to its grid, so a review attaches the same way. Add onApply: baseReviewApplied(...) so an accept does what an edit does in a base: "Last modified time" and "Last modified by" fields are stamped in the same write, and the record's activity credits the change to what proposed it and to the person who accepted it ("CRM agent (accepted by Dana)").

<script lang="ts">
  import { SvBase, enableBase, baseReviewApplied } from '@svgrid/enterprise/base'
  import { createReview, SvReviewPanel } from '@svgrid/enterprise/review'

  enableBase()
  let records = $state(initialRecords)
  let api = $state(null)
  const review = createReview({
    api: () => api,
    setRows: (next) => (records = next),
    onApply: baseReviewApplied(() => api),
  })
</script>

<SvBase
  data={records}
  columns={review.columns(columns)}
  conditionalFormats={review.formats}
  primaryField="name"
  onApiReady={(a) => (api = a)}
/>
<SvReviewPanel {review} />

What a base shows:

With several tables (tables), keep one review per table: proposals are keyed by record id, and ids can repeat across tables. Give the active table's review to the grid and write into that table's rows:

const review = createReview({
  api: () => api,
  setRows: (next) => (tables = tables.map((t) => (t.id === 'tasks' ? { ...t, data: next } : t))),
  onApply: baseReviewApplied(() => api),
})

Wording and languages

Every string the review shows comes from one message map with English defaults: the panel, the group labels, the card and cell marks, and the notes on proposals that were not written. Pass any subset to createReview, and unset keys stay English. {name} placeholders are filled in; REVIEW_MESSAGES lists every key with its English text.

const review = createReview({
  api: () => api,
  setRows: (next) => (rows = next),
  messages: {
    acceptAll: 'Alle annehmen',
    changes: '{count} Änderungen',
    labelWhitespace: 'Leerzeichen in {field} kürzen',
    cardProposed: '{count} vorgeschlagen',
  },
})

<SvReviewPanel messages={...}> overrides the review's wording for one panel.

Licensing

Change review is part of SvGrid Enterprise. Without a license key it runs with a watermark and a one-time notice. It adds nothing to @svgrid/grid: the tint is conditionalFormats, the proposed value is a wrapped cell, and the undo step goes through the grid's public command context.

See also

Live examples

  • 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.
  • Agent review desk - A CRM agent cleans a customer table and nothing it does lands until a person says so. It works through createGridTools (grid_describe, grid_query_rows, grid_propose_edits), and each step shows with the tool call it made. Its edits arrive as proposals: the cell shows the proposed value with the old one struck through, the review panel groups them by what they do with the risky groups first, and two unsure region guesses lead their sample. Accept or reject a group, undo with Ctrl+Z, read every decision in the Log, propose reverting the run, or copy the review as JSON for an approver.

Related articles