From 34add8b9bf9299b407292f404f4222e4faea6f7b Mon Sep 17 00:00:00 2001 From: William Reiske Date: Wed, 27 May 2026 17:08:28 -0700 Subject: [PATCH 1/4] feat(ai): add AIReconciliationPanel human-in-the-loop component A reusable panel + modal for letting users review, edit, and approve AI-proposed field changes (e.g. profile updates extracted by DocumentScanner). Includes confidence-based defaults, inline editors, bulk actions, keyboard shortcut, and full a11y. DocumentScanner now recommends pairing with it in its JSDoc. --- src/components/AI/Reconciliation.stories.tsx | 197 +++++ src/components/AI/Reconciliation.test.tsx | 328 +++++++ src/components/AI/Reconciliation.tsx | 854 +++++++++++++++++++ src/components/AI/index.ts | 12 + src/components/DocumentScanner/index.ts | 12 + 5 files changed, 1403 insertions(+) create mode 100644 src/components/AI/Reconciliation.stories.tsx create mode 100644 src/components/AI/Reconciliation.test.tsx create mode 100644 src/components/AI/Reconciliation.tsx diff --git a/src/components/AI/Reconciliation.stories.tsx b/src/components/AI/Reconciliation.stories.tsx new file mode 100644 index 00000000..a483207d --- /dev/null +++ b/src/components/AI/Reconciliation.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; +import { + AIReconciliationPanel, + type ReconciliationProposal, +} from './Reconciliation'; +import { Button } from '../Button'; + +const meta: Meta = { + title: 'Product/Feature Modules/AI/ReconciliationPanel', + component: AIReconciliationPanel, + parameters: { layout: 'padded' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const licenseProposals: ReconciliationProposal[] = [ + { + id: 'fullName', + label: 'Legal name', + current: 'jane q public', + proposed: 'Jane Q. Public', + confidence: 0.97, + }, + { + id: 'dob', + label: 'Date of birth', + current: null, + proposed: '1990-04-12', + confidence: 0.92, + hint: 'Used to verify your identity on regulated forms.', + }, + { + id: 'address', + label: 'Mailing address', + current: '123 Old St, Anytown, OH 12345', + proposed: '742 Evergreen Ter, Springfield, OH 45501', + confidence: 0.71, + }, + { + id: 'licenseNumber', + label: 'License number', + current: '', + proposed: 'OH-D123-4567', + confidence: 0.55, + hint: 'Low confidence — please double-check before applying.', + }, +]; + +const handleApply = async ( + accepted: Array<{ id: string; value: unknown }> +) => { + // Stories swallow the result; the panel awaits this promise to drive its + // loading state. + void accepted; + await new Promise((resolve) => setTimeout(resolve, 500)); +}; + +export const Default: Story = { + args: { + title: 'Update your profile from your license?', + description: + 'Review the suggested changes and choose which ones to apply.', + source: { + label: "Driver's License", + generatedAt: new Date(Date.now() - 1000 * 60 * 2), + }, + proposals: licenseProposals, + onApply: handleApply, + onSkip: () => undefined, + }, +}; + +function ModalVariantRender( + args: React.ComponentProps +) { + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen(false)} + onApply={async (a) => { + await handleApply(a); + setOpen(false); + }} + /> + + ); +} + +export const ModalVariant: Story = { + render: (args) => , + args: { + title: 'Update your profile from your license?', + description: 'Review the suggested changes and choose which to apply.', + source: { + label: "Driver's License", + generatedAt: new Date(Date.now() - 1000 * 30), + }, + proposals: licenseProposals, + onApply: handleApply, + }, +}; + +export const NothingToReconcile: Story = { + args: { + title: 'Update your profile from your license?', + source: { label: "Driver's License", generatedAt: new Date() }, + proposals: [ + { + id: 'fullName', + label: 'Legal name', + current: 'Jane Q. Public', + proposed: 'jane q public', + confidence: 1, + }, + ], + onApply: handleApply, + onSkip: () => undefined, + }, + parameters: { + docs: { + description: { + story: + 'When every proposal is filtered out as equal, the panel renders nothing and fires `onNothingToReconcile`.', + }, + }, + }, +}; + +export const WithInlineEditor: Story = { + args: { + title: 'Confirm scanned values', + source: { label: 'Ozwell extraction' }, + proposals: [ + { + id: 'fullName', + label: 'Legal name', + current: 'Jane Doe', + proposed: 'Jane Q. Public', + confidence: 0.6, + renderEditor: (value, onChange) => ( + onChange(e.target.value)} + className="border-border bg-background w-full rounded border px-2 py-1 text-sm" + /> + ), + }, + ], + onApply: handleApply, + onSkip: () => undefined, + }, +}; + +export const Grouped: Story = { + args: { + title: 'Update profile from scan', + source: { label: "Driver's License", generatedAt: new Date() }, + proposals: [ + { + id: 'fullName', + label: 'Legal name', + group: 'Identity', + current: 'Old Name', + proposed: 'Jane Q. Public', + confidence: 0.95, + }, + { + id: 'dob', + label: 'Date of birth', + group: 'Identity', + current: null, + proposed: '1990-04-12', + confidence: 0.92, + }, + { + id: 'address', + label: 'Mailing address', + group: 'Contact', + current: '123 Old St', + proposed: '742 Evergreen Ter', + confidence: 0.7, + }, + ], + onApply: handleApply, + onSkip: () => undefined, + }, +}; diff --git a/src/components/AI/Reconciliation.test.tsx b/src/components/AI/Reconciliation.test.tsx new file mode 100644 index 00000000..aff3802e --- /dev/null +++ b/src/components/AI/Reconciliation.test.tsx @@ -0,0 +1,328 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithTheme } from '../../test/test-utils'; +import { + AIReconciliationPanel, + defaultReconciliationIsEqual, + type ReconciliationProposal, +} from './Reconciliation'; + +const basicSource = { label: "Driver's License" }; + +const baseProposals: ReconciliationProposal[] = [ + { + id: 'fullName', + label: 'Legal name', + current: 'old name', + proposed: 'New Name', + confidence: 0.95, + }, + { + id: 'dob', + label: 'Date of birth', + current: null, + proposed: '1990-04-12', + confidence: 0.92, + }, + { + id: 'license', + label: 'License number', + current: '', + proposed: 'OH-D123', + confidence: 0.4, + hint: 'Low confidence', + }, +]; + +describe('defaultReconciliationIsEqual', () => { + it('treats null/undefined/empty string as equal', () => { + expect(defaultReconciliationIsEqual(null, '')).toBe(true); + expect(defaultReconciliationIsEqual(undefined, '')).toBe(true); + expect(defaultReconciliationIsEqual('', null)).toBe(true); + }); + + it('normalizes string whitespace and case', () => { + expect(defaultReconciliationIsEqual('Jane Public', 'jane public')).toBe( + true + ); + }); + + it('compares Dates by epoch', () => { + const d1 = new Date('2020-01-01'); + const d2 = new Date('2020-01-01'); + expect(defaultReconciliationIsEqual(d1, d2)).toBe(true); + }); + + it('returns false for genuinely different values', () => { + expect(defaultReconciliationIsEqual('a', 'b')).toBe(false); + }); +}); + +describe('AIReconciliationPanel', () => { + it('renders title, description, and only differing rows', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Update your profile')).toBeInTheDocument(); + expect(screen.getByText('Pick which to apply.')).toBeInTheDocument(); + expect(screen.getByText('Legal name')).toBeInTheDocument(); + expect(screen.getByText('Date of birth')).toBeInTheDocument(); + expect(screen.getByText('License number')).toBeInTheDocument(); + expect(screen.queryByText('Already matches')).not.toBeInTheDocument(); + }); + + it('defaults low-confidence rows to unchecked, high to checked', () => { + renderWithTheme( + + ); + + const nameCheckbox = screen.getByRole('checkbox', { + name: /apply update for legal name/i, + }); + const licenseCheckbox = screen.getByRole('checkbox', { + name: /apply update for license number/i, + }); + expect(nameCheckbox).toBeChecked(); + expect(licenseCheckbox).not.toBeChecked(); + }); + + it('calls onApply with only accepted rows and their values', async () => { + const user = userEvent.setup(); + const onApply = vi.fn().mockResolvedValue(undefined); + + renderWithTheme( + + ); + + // Apply button label reflects accepted count (2 of 3 default-on) + const applyBtn = screen.getByRole('button', { name: /apply 2 updates/i }); + await user.click(applyBtn); + + expect(onApply).toHaveBeenCalledTimes(1); + const accepted = onApply.mock.calls[0][0] as Array<{ + id: string; + value: unknown; + }>; + expect(accepted.map((a) => a.id).sort()).toEqual(['dob', 'fullName']); + expect(accepted.find((a) => a.id === 'fullName')?.value).toBe('New Name'); + }); + + it('bulk toggle selects and deselects all non-required rows', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + const bulk = screen.getByRole('checkbox', { name: /accept all/i }); + await user.click(bulk); + + const licenseCheckbox = screen.getByRole('checkbox', { + name: /apply update for license number/i, + }); + expect(licenseCheckbox).toBeChecked(); + + const rejectAll = screen.getByRole('checkbox', { name: /reject all/i }); + await user.click(rejectAll); + + expect(licenseCheckbox).not.toBeChecked(); + }); + + it('disables apply when nothing is accepted', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + const nameCheckbox = screen.getByRole('checkbox', { + name: /apply update for legal name/i, + }); + const dobCheckbox = screen.getByRole('checkbox', { + name: /apply update for date of birth/i, + }); + await user.click(nameCheckbox); + await user.click(dobCheckbox); + + const applyBtn = screen.getByRole('button', { name: /apply 0 updates/i }); + expect(applyBtn).toBeDisabled(); + }); + + it('fires onSkip when skip button is clicked', async () => { + const user = userEvent.setup(); + const onSkip = vi.fn(); + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: /skip for now/i })); + expect(onSkip).toHaveBeenCalledTimes(1); + }); + + it('required rows cannot be unchecked', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + const cb = screen.getByRole('checkbox', { + name: /apply update for legal name/i, + }); + expect(cb).toBeChecked(); + expect(cb).toBeDisabled(); + await user.click(cb); + expect(cb).toBeChecked(); + }); + + it('renders nothing in panel variant when all proposals match', () => { + const onNothing = vi.fn(); + const { container } = renderWithTheme( + + ); + + expect(container.firstChild).toBeNull(); + expect(onNothing).toHaveBeenCalledTimes(1); + }); + + it('inline editor updates the value sent to onApply', async () => { + const user = userEvent.setup(); + const onApply = vi.fn().mockResolvedValue(undefined); + + renderWithTheme( + ( + onChange(e.target.value)} + /> + ), + }, + ]} + onApply={onApply} + /> + ); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + const input = screen.getByRole('textbox', { name: /edit legal name/i }); + await user.clear(input); + await user.type(input, 'Edited'); + + await user.click(screen.getByRole('button', { name: /apply 1 update/i })); + + expect(onApply).toHaveBeenCalledTimes(1); + const accepted = onApply.mock.calls[0][0] as Array<{ + id: string; + value: unknown; + }>; + expect(accepted[0]).toEqual({ id: 'fullName', value: 'Edited' }); + }); + + it('modal variant renders inside dialog when open', () => { + renderWithTheme( + + ); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Confirm scan')).toBeInTheDocument(); + expect(within(dialog).getByText('Legal name')).toBeInTheDocument(); + }); + + it('modal variant does not render when closed', () => { + renderWithTheme( + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/AI/Reconciliation.tsx b/src/components/AI/Reconciliation.tsx new file mode 100644 index 00000000..4dded4e7 --- /dev/null +++ b/src/components/AI/Reconciliation.tsx @@ -0,0 +1,854 @@ +/** + * AI Reconciliation Panel + * + * A generic "human-in-the-loop" review surface for AI-proposed changes. + * Renders a list of field-level proposals (current → proposed), lets the + * user accept/reject/edit each one, and emits the final accepted set via + * `onApply`. + * + * Use cases: + * - Document scanner suggesting profile updates from a scanned ID + * - CSV import suggesting column → field mappings + * - AI-generated form drafts that the user reviews before saving + * - Any "AI proposes, human approves" workflow + * + * Two variants: + * - `panel` — inline card, drop anywhere + * - `modal` — wraps the panel in `` from @mieweb/ui + * + * @example + * ```tsx + * { await save(accepted); }} + * onSkip={() => setOpen(false)} + * /> + * ``` + */ + +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../utils/cn'; +import { Button } from '../Button'; +import { Checkbox } from '../Checkbox'; +import { + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, +} from '../Modal'; +import { SparklesIcon } from './icons'; + +// ============================================================================ +// Types +// ============================================================================ + +export type ReconciliationConfidenceLevel = 'high' | 'medium' | 'low'; + +/** + * A single field-level change being proposed by an AI source. + * + * The component compares `current` and `proposed` using `isEqual` (or the + * default normalizer) and silently drops rows that are effectively equal, + * so callers can pass every candidate field without pre-filtering. + */ +export interface ReconciliationProposal { + /** Stable id used as the key in the `onApply` payload (e.g. `address`). */ + id: string; + /** Short, user-facing label for the field (e.g. `Date of birth`). */ + label: string; + /** Optional subtext describing the field. */ + description?: string; + /** Current value on file (null / undefined / `''` render as `—`). */ + current: unknown; + /** Proposed value from the AI source. */ + proposed: unknown; + /** Optional grouping key. Rows with the same `group` render together. */ + group?: string; + /** + * Model confidence in [0, 1]. When provided and not overridden by + * `confidenceLevel`, values < 0.6 are treated as `low` (row defaults to + * unchecked), 0.6–0.85 as `medium`, ≥ 0.85 as `high`. + */ + confidence?: number; + /** Explicit confidence level override. Wins over numeric `confidence`. */ + confidenceLevel?: ReconciliationConfidenceLevel; + /** + * Force the row's initial accepted state. Defaults to `true` for high / + * medium confidence and `false` for low confidence. + */ + defaultAccepted?: boolean; + /** Custom renderer for the current / proposed values. */ + renderValue?: (value: unknown) => React.ReactNode; + /** + * Inline editor. When provided, an `Edit` button appears that expands the + * proposed value into the editor. Call `onChange` with the new value. + */ + renderEditor?: ( + value: unknown, + onChange: (next: unknown) => void + ) => React.ReactNode; + /** Optional hint shown under the row. */ + hint?: string; + /** Required rows cannot be unchecked. */ + required?: boolean; +} + +/** Identifies where the proposals came from. Shown in the header. */ +export interface ReconciliationSource { + /** Short label, e.g. `Driver's License` or `Ozwell extraction`. */ + label: string; + /** Optional thumbnail / preview URL shown beside the source label. */ + thumbnailUrl?: string; + /** When the source produced these values. */ + generatedAt?: Date; + /** Override the default sparkles icon. */ + icon?: React.ReactNode; +} + +export interface ReconciliationAcceptedChange { + id: string; + value: unknown; +} + +export interface AIReconciliationPanelProps + extends VariantProps { + /** Headline, e.g. `Update your profile from your license?` */ + title: string; + /** Optional explainer rendered under the title. */ + description?: React.ReactNode; + /** Provenance metadata shown in the header. */ + source: ReconciliationSource; + /** All candidate changes. Equal rows are filtered out automatically. */ + proposals: ReconciliationProposal[]; + /** + * Called when the user clicks Apply. Receives only the accepted rows + * (with their possibly-edited value). Async — the button shows a spinner + * while the promise is pending. + */ + onApply: ( + accepted: ReconciliationAcceptedChange[] + ) => Promise | void; + /** Called when the user dismisses without applying. */ + onSkip?: () => void; + /** Render mode. Defaults to `panel`. */ + variant?: 'panel' | 'modal'; + /** Required when `variant="modal"`. */ + open?: boolean; + /** Required when `variant="modal"`. */ + onOpenChange?: (open: boolean) => void; + /** Override button labels. */ + applyLabel?: string; + skipLabel?: string; + acceptAllLabel?: string; + rejectAllLabel?: string; + /** Hide the bulk accept / reject toggle. */ + hideBulkActions?: boolean; + /** + * Equality test used to drop `no real change` rows. Default normalizes + * strings (trim, collapse whitespace, case-insensitive), compares Dates by + * epoch, and falls back to JSON for plain objects / arrays. + */ + isEqual?: (current: unknown, proposed: unknown) => boolean; + /** Fires once when every proposal is filtered out as equal. */ + onNothingToReconcile?: () => void; + /** Additional class names on the outer container. */ + className?: string; +} + +// ============================================================================ +// Equality helpers +// ============================================================================ + +function normalizeString(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** + * Default equality used to filter out cosmetic-only diffs. Treats null, + * undefined, and `''` as equivalent; ignores case and whitespace for strings; + * compares Dates by epoch; falls back to JSON for plain objects / arrays. + */ +export function defaultReconciliationIsEqual( + current: unknown, + proposed: unknown +): boolean { + const a = current ?? ''; + const b = proposed ?? ''; + if (a === b) return true; + if (typeof a === 'string' && typeof b === 'string') { + return normalizeString(a) === normalizeString(b); + } + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + if (typeof a === 'object' && typeof b === 'object' && a && b) { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } + } + return false; +} + +function resolveConfidenceLevel( + p: ReconciliationProposal +): ReconciliationConfidenceLevel | undefined { + if (p.confidenceLevel) return p.confidenceLevel; + if (typeof p.confidence !== 'number') return undefined; + if (p.confidence >= 0.85) return 'high'; + if (p.confidence >= 0.6) return 'medium'; + return 'low'; +} + +function defaultAcceptedFor(p: ReconciliationProposal): boolean { + if (typeof p.defaultAccepted === 'boolean') return p.defaultAccepted; + if (p.required) return true; + return resolveConfidenceLevel(p) !== 'low'; +} + +// ============================================================================ +// Variants +// ============================================================================ + +const panelVariants = cva( + [ + 'rounded-xl bg-card text-card-foreground', + 'border border-border shadow-sm', + 'flex flex-col overflow-hidden', + ], + { + variants: { + tone: { + default: '', + accent: 'ring-1 ring-primary-200 dark:ring-primary-900', + }, + }, + defaultVariants: { + tone: 'default', + }, + } +); + +const confidenceBadgeVariants = cva( + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', + { + variants: { + level: { + high: 'bg-success-100 text-success dark:bg-success-900/30 dark:text-success-300', + medium: + 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + low: 'bg-amber-200 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200', + }, + }, + defaultVariants: { level: 'high' }, + } +); + +// ============================================================================ +// Row +// ============================================================================ + +interface RowState { + accepted: boolean; + editing: boolean; + value: unknown; +} + +function formatValueDefault(value: unknown): React.ReactNode { + if (value === null || value === undefined || value === '') { + return ( + + — + + ); + } + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +function ConfidenceBadge({ + level, +}: { + level: ReconciliationConfidenceLevel; +}) { + const labels: Record = { + high: 'High confidence', + medium: 'Medium confidence', + low: 'Low confidence', + }; + return ( + + {labels[level]} + + ); +} + +interface ReconciliationProposalRowProps { + proposal: ReconciliationProposal; + state: RowState; + onAcceptedChange: (accepted: boolean) => void; + onValueChange: (value: unknown) => void; + onToggleEditing: () => void; +} + +function ReconciliationProposalRow({ + proposal, + state, + onAcceptedChange, + onValueChange, + onToggleEditing, +}: ReconciliationProposalRowProps) { + const rowId = React.useId(); + const checkboxId = `${rowId}-accept`; + const level = resolveConfidenceLevel(proposal); + const render = proposal.renderValue ?? formatValueDefault; + const canEdit = Boolean(proposal.renderEditor); + + return ( +
  • +
    + onAcceptedChange(e.target.checked)} + disabled={proposal.required} + aria-label={`Apply update for ${proposal.label}`} + /> +
    + +
    +
    + +
    + {level && } + {canEdit && ( + + )} +
    +
    + + {proposal.description && ( +

    + {proposal.description} +

    + )} + +
    +
    +
    + On file +
    +
    + {render(proposal.current)} +
    +
    +
    +
    + + From AI + (AI-suggested value) +
    +
    + {state.editing && proposal.renderEditor + ? proposal.renderEditor(state.value, onValueChange) + : render(state.value)} +
    +
    +
    + + {proposal.hint && ( +

    + {proposal.hint} +

    + )} +
    +
  • + ); +} + +// ============================================================================ +// Header bits +// ============================================================================ + +function relativeTimeLabel(date: Date): string { + const seconds = Math.max( + 0, + Math.round((Date.now() - date.getTime()) / 1000) + ); + if (seconds < 45) return 'just now'; + if (seconds < 90) return '1 minute ago'; + if (seconds < 3600) return `${Math.round(seconds / 60)} minutes ago`; + if (seconds < 5400) return '1 hour ago'; + if (seconds < 86400) return `${Math.round(seconds / 3600)} hours ago`; + return date.toLocaleString(); +} + +function SourcePill({ source }: { source: ReconciliationSource }) { + return ( +
    + + {source.icon ?? } + + {source.thumbnailUrl && ( + + )} + {source.label} + {source.generatedAt && ( + + · {relativeTimeLabel(source.generatedAt)} + + )} +
    + ); +} + +// ============================================================================ +// Main component +// ============================================================================ + +/** + * Human-in-the-loop review panel for AI-proposed changes. + * + * Render this whenever an AI process has produced a set of values you'd like + * the user to confirm before persisting. The panel handles diff filtering, + * per-row accept / reject, bulk actions, inline editing, and confidence-aware + * defaults. + * + * @see {@link ReconciliationProposal} for per-field options. + */ +function AIReconciliationPanel({ + title, + description, + source, + proposals, + onApply, + onSkip, + variant = 'panel', + open, + onOpenChange, + tone, + applyLabel, + skipLabel = 'Skip for now', + acceptAllLabel = 'Accept all', + rejectAllLabel = 'Reject all', + hideBulkActions, + isEqual = defaultReconciliationIsEqual, + onNothingToReconcile, + className, +}: AIReconciliationPanelProps) { + // Drop rows that are effectively equal ------------------------------------ + const effective = React.useMemo( + () => proposals.filter((p) => !isEqual(p.current, p.proposed)), + [proposals, isEqual] + ); + + const idsKey = effective.map((p) => p.id).join('|'); + + const [rowStates, setRowStates] = React.useState>( + () => + Object.fromEntries( + effective.map((p) => [ + p.id, + { + accepted: defaultAcceptedFor(p), + editing: false, + value: p.proposed, + }, + ]) + ) + ); + + // If the proposal set changes, reset internal state. + React.useEffect(() => { + setRowStates( + Object.fromEntries( + effective.map((p) => [ + p.id, + { + accepted: defaultAcceptedFor(p), + editing: false, + value: p.proposed, + }, + ]) + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idsKey]); + + // Empty-state callback ---------------------------------------------------- + const reportedEmpty = React.useRef(false); + React.useEffect(() => { + if (effective.length === 0 && !reportedEmpty.current) { + reportedEmpty.current = true; + onNothingToReconcile?.(); + } + if (effective.length > 0) reportedEmpty.current = false; + }, [effective.length, onNothingToReconcile]); + + const acceptedCount = React.useMemo( + () => Object.values(rowStates).filter((s) => s.accepted).length, + [rowStates] + ); + const [submitting, setSubmitting] = React.useState(false); + + const setAllAccepted = React.useCallback( + (accepted: boolean) => { + setRowStates((prev) => { + const next: Record = { ...prev }; + for (const p of effective) { + if (p.required && !accepted) continue; + next[p.id] = { ...prev[p.id], accepted }; + } + return next; + }); + }, + [effective] + ); + + const setRowAccepted = React.useCallback((id: string, accepted: boolean) => { + setRowStates((prev) => ({ ...prev, [id]: { ...prev[id], accepted } })); + }, []); + + const setRowValue = React.useCallback((id: string, value: unknown) => { + setRowStates((prev) => ({ ...prev, [id]: { ...prev[id], value } })); + }, []); + + const toggleRowEditing = React.useCallback((id: string) => { + setRowStates((prev) => ({ + ...prev, + [id]: { ...prev[id], editing: !prev[id].editing }, + })); + }, []); + + const handleApply = React.useCallback(async () => { + const accepted: ReconciliationAcceptedChange[] = effective + .filter((p) => rowStates[p.id]?.accepted) + .map((p) => ({ id: p.id, value: rowStates[p.id].value })); + if (accepted.length === 0) return; + try { + setSubmitting(true); + await onApply(accepted); + } finally { + setSubmitting(false); + } + }, [effective, rowStates, onApply]); + + // Keyboard shortcut: `A` toggles accept-all when focus is inside ---------- + const containerRef = React.useRef(null); + React.useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + const handler = (e: KeyboardEvent) => { + if (e.key !== 'a' && e.key !== 'A') return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + const tag = target?.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) { + return; + } + e.preventDefault(); + setAllAccepted(acceptedCount !== effective.length); + }; + el.addEventListener('keydown', handler); + return () => el.removeEventListener('keydown', handler); + }, [acceptedCount, effective.length, setAllAccepted]); + + const allAccepted = + effective.length > 0 && acceptedCount === effective.length; + const someAccepted = acceptedCount > 0 && !allAccepted; + + const resolvedApplyLabel = + applyLabel ?? + (acceptedCount === 1 ? 'Apply 1 update' : `Apply ${acceptedCount} updates`); + + // Group rows -------------------------------------------------------------- + const grouped = React.useMemo(() => { + const map = new Map(); + for (const p of effective) { + const key = p.group; + const arr = map.get(key) ?? []; + arr.push(p); + map.set(key, arr); + } + return Array.from(map.entries()); + }, [effective]); + + // ------------------------------------------------------------------------- + // Sub-renderers + // ------------------------------------------------------------------------- + const bulkBar = + !hideBulkActions && effective.length > 1 ? ( +
    + setAllAccepted(e.target.checked)} + aria-label={allAccepted ? rejectAllLabel : acceptAllLabel} + label={allAccepted ? rejectAllLabel : acceptAllLabel} + /> +

    + {acceptedCount} of {effective.length} selected +

    +
    + ) : null; + + const rowList = ( +
      + {grouped.map(([groupKey, items]) => ( + + {groupKey && ( + + )} + {items.map((p) => ( + setRowAccepted(p.id, a)} + onValueChange={(v) => setRowValue(p.id, v)} + onToggleEditing={() => toggleRowEditing(p.id)} + /> + ))} + + ))} +
    + ); + + const footerButtons = ( + <> + {onSkip && ( + + )} + + + ); + + // ------------------------------------------------------------------------- + // Modal variant + // ------------------------------------------------------------------------- + if (variant === 'modal') { + if (!onOpenChange) { + throw new Error( + 'AIReconciliationPanel: `onOpenChange` is required when `variant="modal"`.' + ); + } + return ( + + + {title} + + + +
    +
    + {description && ( +

    {description}

    + )} +
    + +
    +
    + {effective.length === 0 ? ( +

    + No updates to review — your profile already matches the scan. +

    + ) : ( + <> + {bulkBar} + {rowList} + + )} +
    +
    + + {effective.length === 0 ? ( + + ) : ( + footerButtons + )} + +
    + ); + } + + // ------------------------------------------------------------------------- + // Panel variant + // ------------------------------------------------------------------------- + if (effective.length === 0) return null; + + return ( +
    +
    +
    +
    +

    + {title} +

    + {description && ( +

    + {description} +

    + )} +
    + +
    +
    + {bulkBar} + {rowList} +
    + {footerButtons} +
    +
    + ); +} + +AIReconciliationPanel.displayName = 'AIReconciliationPanel'; + +export { + AIReconciliationPanel, + panelVariants as reconciliationPanelVariants, +}; diff --git a/src/components/AI/index.ts b/src/components/AI/index.ts index 7b2fe37b..c26cfd53 100644 --- a/src/components/AI/index.ts +++ b/src/components/AI/index.ts @@ -61,3 +61,15 @@ export { type AIChatTriggerProps, type FloatingAIChatProps, } from './AIChatModal'; + +// AI Reconciliation Panel +export { + AIReconciliationPanel, + defaultReconciliationIsEqual, + reconciliationPanelVariants, + type AIReconciliationPanelProps, + type ReconciliationProposal, + type ReconciliationSource, + type ReconciliationAcceptedChange, + type ReconciliationConfidenceLevel, +} from './Reconciliation'; diff --git a/src/components/DocumentScanner/index.ts b/src/components/DocumentScanner/index.ts index b7544a66..54792f01 100644 --- a/src/components/DocumentScanner/index.ts +++ b/src/components/DocumentScanner/index.ts @@ -38,6 +38,18 @@ * ); * } * ``` + * + * @remarks + * **Tip:** When the scan returns structured fields that you'd like the user + * to confirm before writing to a profile or record, pair `DocumentScanner` + * with `AIReconciliationPanel` (also in `@mieweb/ui`). The reconciliation + * panel is the recommended "human-in-the-loop" review surface for any + * AI-extracted data — it diffs the proposed values against what's on file, + * surfaces per-field confidence, and lets the user accept, edit, or reject + * each change. + * + * `DocumentScanner` does not depend on `AIReconciliationPanel`; consumers + * compose them as needed. */ export { DocumentScanner } from './DocumentScanner'; From d5177a61d90483bbfd6d7021af69cf4e0c115a91 Mon Sep 17 00:00:00 2001 From: William Reiske Date: Wed, 27 May 2026 18:01:05 -0700 Subject: [PATCH 2/4] fix(ai-reconciliation): address PR #244 review feedback - Use sorted-key stable stringify in defaultReconciliationIsEqual so JSON-equivalent objects with differing key order compare equal. - Reset internal row state on any meaningful proposal change (proposed value, defaultAccepted, required, confidence), not just on id-set changes. - Defensively default missing row state during render and in row mutators so the component does not crash when proposals add new ids before the reset effect runs. - Add Cmd/Ctrl+Enter keyboard shortcut to submit (Apply) so the keyboard story matches the docs. - Add Reconciliation-specific utility strings to miewebUISafelist so Tailwind 3 consumers using the preset get the right styles. --- src/components/AI/Reconciliation.tsx | 148 +++++++++++++++++++++------ src/tailwind-preset.ts | 26 +++++ 2 files changed, 144 insertions(+), 30 deletions(-) diff --git a/src/components/AI/Reconciliation.tsx b/src/components/AI/Reconciliation.tsx index 4dded4e7..41f268cb 100644 --- a/src/components/AI/Reconciliation.tsx +++ b/src/components/AI/Reconciliation.tsx @@ -176,10 +176,27 @@ function normalizeString(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'; + const keys = Object.keys(value as Record).sort(); + return ( + '{' + + keys + .map( + (k) => + JSON.stringify(k) + ':' + stableStringify((value as Record)[k]) + ) + .join(',') + + '}' + ); +} + /** * Default equality used to filter out cosmetic-only diffs. Treats null, * undefined, and `''` as equivalent; ignores case and whitespace for strings; - * compares Dates by epoch; falls back to JSON for plain objects / arrays. + * compares Dates by epoch; compares plain objects / arrays via a key-sorted + * stringify so insertion order doesn't matter. */ export function defaultReconciliationIsEqual( current: unknown, @@ -196,7 +213,7 @@ export function defaultReconciliationIsEqual( } if (typeof a === 'object' && typeof b === 'object' && a && b) { try { - return JSON.stringify(a) === JSON.stringify(b); + return stableStringify(a) === stableStringify(b); } catch { return false; } @@ -309,7 +326,7 @@ function ConfidenceBadge({ interface ReconciliationProposalRowProps { proposal: ReconciliationProposal; - state: RowState; + state: RowState | undefined; onAcceptedChange: (accepted: boolean) => void; onValueChange: (value: unknown) => void; onToggleEditing: () => void; @@ -322,6 +339,14 @@ function ReconciliationProposalRow({ onValueChange, onToggleEditing, }: ReconciliationProposalRowProps) { + // `state` can briefly be undefined immediately after `proposals` changes + // and before the reset effect runs. Fall back to the proposal's defaults + // rather than throwing on `state.accepted`. + const safeState: RowState = state ?? { + accepted: defaultAcceptedFor(proposal), + editing: false, + value: proposal.proposed, + }; const rowId = React.useId(); const checkboxId = `${rowId}-accept`; const level = resolveConfidenceLevel(proposal); @@ -331,17 +356,17 @@ function ReconciliationProposalRow({ return (
  • onAcceptedChange(e.target.checked)} disabled={proposal.required} aria-label={`Apply update for ${proposal.label}`} @@ -378,10 +403,10 @@ function ReconciliationProposalRow({ 'focus-visible:ring-ring rounded focus-visible:ring-2 focus-visible:outline-none', 'hover:underline' )} - aria-expanded={state.editing} + aria-expanded={safeState.editing} aria-controls={`${rowId}-editor`} > - {state.editing ? 'Done' : 'Edit'} + {safeState.editing ? 'Done' : 'Edit'} )}
    @@ -405,7 +430,7 @@ function ReconciliationProposalRow({
    - {state.editing && proposal.renderEditor - ? proposal.renderEditor(state.value, onValueChange) - : render(state.value)} + {safeState.editing && proposal.renderEditor + ? proposal.renderEditor(safeState.value, onValueChange) + : render(safeState.value)}
    @@ -534,7 +559,24 @@ function AIReconciliationPanel({ [proposals, isEqual] ); - const idsKey = effective.map((p) => p.id).join('|'); + // A signature that changes whenever any field we use for initial state + // changes — id, proposed value, defaultAccepted, required, or confidence. + // Without this, an updated `proposed` value on an existing id would leave + // stale state in place and `handleApply` could submit the old value. + const stateSignature = React.useMemo( + () => + effective + .map( + (p) => + `${p.id}\u241F${stableStringify(p.proposed)}\u241F${ + p.defaultAccepted ?? '' + }\u241F${p.required ?? ''}\u241F${p.confidence ?? ''}\u241F${ + p.confidenceLevel ?? '' + }` + ) + .join('|'), + [effective] + ); const [rowStates, setRowStates] = React.useState>( () => @@ -565,7 +607,7 @@ function AIReconciliationPanel({ ) ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [idsKey]); + }, [stateSignature]); // Empty-state callback ---------------------------------------------------- const reportedEmpty = React.useRef(false); @@ -589,7 +631,12 @@ function AIReconciliationPanel({ const next: Record = { ...prev }; for (const p of effective) { if (p.required && !accepted) continue; - next[p.id] = { ...prev[p.id], accepted }; + const base: RowState = prev[p.id] ?? { + accepted: defaultAcceptedFor(p), + editing: false, + value: p.proposed, + }; + next[p.id] = { ...base, accepted }; } return next; }); @@ -597,25 +644,58 @@ function AIReconciliationPanel({ [effective] ); - const setRowAccepted = React.useCallback((id: string, accepted: boolean) => { - setRowStates((prev) => ({ ...prev, [id]: { ...prev[id], accepted } })); - }, []); + const setRowAccepted = React.useCallback( + (id: string, accepted: boolean) => { + const proposal = effective.find((p) => p.id === id); + setRowStates((prev) => { + const base: RowState = prev[id] ?? { + accepted: proposal ? defaultAcceptedFor(proposal) : false, + editing: false, + value: proposal?.proposed, + }; + return { ...prev, [id]: { ...base, accepted } }; + }); + }, + [effective] + ); - const setRowValue = React.useCallback((id: string, value: unknown) => { - setRowStates((prev) => ({ ...prev, [id]: { ...prev[id], value } })); - }, []); + const setRowValue = React.useCallback( + (id: string, value: unknown) => { + const proposal = effective.find((p) => p.id === id); + setRowStates((prev) => { + const base: RowState = prev[id] ?? { + accepted: proposal ? defaultAcceptedFor(proposal) : false, + editing: false, + value: proposal?.proposed, + }; + return { ...prev, [id]: { ...base, value } }; + }); + }, + [effective] + ); - const toggleRowEditing = React.useCallback((id: string) => { - setRowStates((prev) => ({ - ...prev, - [id]: { ...prev[id], editing: !prev[id].editing }, - })); - }, []); + const toggleRowEditing = React.useCallback( + (id: string) => { + const proposal = effective.find((p) => p.id === id); + setRowStates((prev) => { + const base: RowState = prev[id] ?? { + accepted: proposal ? defaultAcceptedFor(proposal) : false, + editing: false, + value: proposal?.proposed, + }; + return { ...prev, [id]: { ...base, editing: !base.editing } }; + }); + }, + [effective] + ); const handleApply = React.useCallback(async () => { const accepted: ReconciliationAcceptedChange[] = effective .filter((p) => rowStates[p.id]?.accepted) - .map((p) => ({ id: p.id, value: rowStates[p.id].value })); + .map((p) => ({ + id: p.id, + value: rowStates[p.id]?.value ?? p.proposed, + })); if (accepted.length === 0) return; try { setSubmitting(true); @@ -625,12 +705,20 @@ function AIReconciliationPanel({ } }, [effective, rowStates, onApply]); - // Keyboard shortcut: `A` toggles accept-all when focus is inside ---------- + // Keyboard shortcuts: + // - `A` toggles accept-all when focus is inside + // - `Cmd/Ctrl+Enter` submits (Apply) const containerRef = React.useRef(null); React.useEffect(() => { const el = containerRef.current; if (!el) return undefined; const handler = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (acceptedCount === 0 || submitting) return; + e.preventDefault(); + void handleApply(); + return; + } if (e.key !== 'a' && e.key !== 'A') return; if (e.metaKey || e.ctrlKey || e.altKey) return; const target = e.target as HTMLElement | null; @@ -643,7 +731,7 @@ function AIReconciliationPanel({ }; el.addEventListener('keydown', handler); return () => el.removeEventListener('keydown', handler); - }, [acceptedCount, effective.length, setAllAccepted]); + }, [acceptedCount, effective.length, setAllAccepted, handleApply, submitting]); const allAccepted = effective.length > 0 && acceptedCount === effective.length; diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts index d89592fd..a07b3ba5 100644 --- a/src/tailwind-preset.ts +++ b/src/tailwind-preset.ts @@ -302,6 +302,32 @@ export const miewebUISafelist = [ 'overflow-hidden', // Select component 'truncate', + // AIReconciliationPanel + 'ring-primary-200', + 'dark:ring-primary-900', + 'border-primary-300', + 'dark:border-primary-700', + 'bg-primary-50/60', + 'dark:bg-primary-950/30', + 'text-primary-700', + 'dark:text-primary-400', + 'border-border/60', + 'bg-muted/40', + 'bg-muted/60', + 'divide-border', + 'max-h-[60vh]', + 'bg-success-100', + 'dark:bg-success-900/30', + 'bg-amber-100', + 'bg-amber-200', + 'text-amber-700', + 'text-amber-800', + 'text-amber-200', + 'text-amber-300', + 'dark:bg-amber-900/30', + 'dark:bg-amber-900/40', + 'dark:text-amber-200', + 'dark:text-amber-300', ]; export interface MiewebUIPreset { From b6eb51abf1e217984d82bddc85551c997b00db56 Mon Sep 17 00:00:00 2001 From: William Reiske Date: Wed, 27 May 2026 18:14:32 -0700 Subject: [PATCH 3/4] style(ai-reconciliation): apply prettier formatting --- src/components/AI/Reconciliation.stories.tsx | 7 +--- src/components/AI/Reconciliation.tsx | 44 +++++++++----------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/components/AI/Reconciliation.stories.tsx b/src/components/AI/Reconciliation.stories.tsx index a483207d..7fb44220 100644 --- a/src/components/AI/Reconciliation.stories.tsx +++ b/src/components/AI/Reconciliation.stories.tsx @@ -49,9 +49,7 @@ const licenseProposals: ReconciliationProposal[] = [ }, ]; -const handleApply = async ( - accepted: Array<{ id: string; value: unknown }> -) => { +const handleApply = async (accepted: Array<{ id: string; value: unknown }>) => { // Stories swallow the result; the panel awaits this promise to drive its // loading state. void accepted; @@ -61,8 +59,7 @@ const handleApply = async ( export const Default: Story = { args: { title: 'Update your profile from your license?', - description: - 'Review the suggested changes and choose which ones to apply.', + description: 'Review the suggested changes and choose which ones to apply.', source: { label: "Driver's License", generatedAt: new Date(Date.now() - 1000 * 60 * 2), diff --git a/src/components/AI/Reconciliation.tsx b/src/components/AI/Reconciliation.tsx index 41f268cb..52d1561b 100644 --- a/src/components/AI/Reconciliation.tsx +++ b/src/components/AI/Reconciliation.tsx @@ -123,8 +123,9 @@ export interface ReconciliationAcceptedChange { value: unknown; } -export interface AIReconciliationPanelProps - extends VariantProps { +export interface AIReconciliationPanelProps extends VariantProps< + typeof panelVariants +> { /** Headline, e.g. `Update your profile from your license?` */ title: string; /** Optional explainer rendered under the title. */ @@ -138,9 +139,7 @@ export interface AIReconciliationPanelProps * (with their possibly-edited value). Async — the button shows a spinner * while the promise is pending. */ - onApply: ( - accepted: ReconciliationAcceptedChange[] - ) => Promise | void; + onApply: (accepted: ReconciliationAcceptedChange[]) => Promise | void; /** Called when the user dismisses without applying. */ onSkip?: () => void; /** Render mode. Defaults to `panel`. */ @@ -178,14 +177,17 @@ function normalizeString(value: string): string { function stableStringify(value: unknown): string { if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'; + if (Array.isArray(value)) + return '[' + value.map(stableStringify).join(',') + ']'; const keys = Object.keys(value as Record).sort(); return ( '{' + keys .map( (k) => - JSON.stringify(k) + ':' + stableStringify((value as Record)[k]) + JSON.stringify(k) + + ':' + + stableStringify((value as Record)[k]) ) .join(',') + '}' @@ -304,11 +306,7 @@ function formatValueDefault(value: unknown): React.ReactNode { return String(value); } -function ConfidenceBadge({ - level, -}: { - level: ReconciliationConfidenceLevel; -}) { +function ConfidenceBadge({ level }: { level: ReconciliationConfidenceLevel }) { const labels: Record = { high: 'High confidence', medium: 'Medium confidence', @@ -476,10 +474,7 @@ function ReconciliationProposalRow({ // ============================================================================ function relativeTimeLabel(date: Date): string { - const seconds = Math.max( - 0, - Math.round((Date.now() - date.getTime()) / 1000) - ); + const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); if (seconds < 45) return 'just now'; if (seconds < 90) return '1 minute ago'; if (seconds < 3600) return `${Math.round(seconds / 60)} minutes ago`; @@ -731,7 +726,13 @@ function AIReconciliationPanel({ }; el.addEventListener('keydown', handler); return () => el.removeEventListener('keydown', handler); - }, [acceptedCount, effective.length, setAllAccepted, handleApply, submitting]); + }, [ + acceptedCount, + effective.length, + setAllAccepted, + handleApply, + submitting, + ]); const allAccepted = effective.length > 0 && acceptedCount === effective.length; @@ -907,9 +908,7 @@ function AIReconciliationPanel({
    -

    - {title} -

    +

    {title}

    {description && (

    {description} @@ -936,7 +935,4 @@ function AIReconciliationPanel({ AIReconciliationPanel.displayName = 'AIReconciliationPanel'; -export { - AIReconciliationPanel, - panelVariants as reconciliationPanelVariants, -}; +export { AIReconciliationPanel, panelVariants as reconciliationPanelVariants }; From 29a3d8f3fa20e9af27f2faa0d6738849b87bf5f2 Mon Sep 17 00:00:00 2001 From: William Reiske Date: Wed, 27 May 2026 18:30:24 -0700 Subject: [PATCH 4/4] fix(ai-reconciliation): address PR #244 follow-up review - Make stableStringify cycle-safe and wrap stateSignature in safeStableStringify so non-serializable proposal values cannot crash render. - Replace AIReconciliationPanelProps with a discriminated union: panel variant forbids open/onOpenChange, modal variant requires them at the type level. - Add role=status alongside aria-live=polite on the bulk-selection counter so the dynamic count is an actual live region (the container remains role=group). - Pass onNothingToReconcile to the NothingToReconcile story so docs and behavior match. - Add ring-1, last:border-b-0, border-dashed, text-[11px] to miewebUISafelist. --- src/components/AI/Reconciliation.stories.tsx | 1 + src/components/AI/Reconciliation.tsx | 56 +++++++++++++++----- src/tailwind-preset.ts | 4 ++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/components/AI/Reconciliation.stories.tsx b/src/components/AI/Reconciliation.stories.tsx index 7fb44220..6ca356bc 100644 --- a/src/components/AI/Reconciliation.stories.tsx +++ b/src/components/AI/Reconciliation.stories.tsx @@ -121,6 +121,7 @@ export const NothingToReconcile: Story = { ], onApply: handleApply, onSkip: () => undefined, + onNothingToReconcile: () => undefined, }, parameters: { docs: { diff --git a/src/components/AI/Reconciliation.tsx b/src/components/AI/Reconciliation.tsx index 52d1561b..dcb03b5e 100644 --- a/src/components/AI/Reconciliation.tsx +++ b/src/components/AI/Reconciliation.tsx @@ -123,7 +123,7 @@ export interface ReconciliationAcceptedChange { value: unknown; } -export interface AIReconciliationPanelProps extends VariantProps< +export interface AIReconciliationPanelBaseProps extends VariantProps< typeof panelVariants > { /** Headline, e.g. `Update your profile from your license?` */ @@ -142,12 +142,6 @@ export interface AIReconciliationPanelProps extends VariantProps< onApply: (accepted: ReconciliationAcceptedChange[]) => Promise | void; /** Called when the user dismisses without applying. */ onSkip?: () => void; - /** Render mode. Defaults to `panel`. */ - variant?: 'panel' | 'modal'; - /** Required when `variant="modal"`. */ - open?: boolean; - /** Required when `variant="modal"`. */ - onOpenChange?: (open: boolean) => void; /** Override button labels. */ applyLabel?: string; skipLabel?: string; @@ -167,6 +161,25 @@ export interface AIReconciliationPanelProps extends VariantProps< className?: string; } +export interface AIReconciliationPanelVariantProps extends AIReconciliationPanelBaseProps { + /** Render mode. Defaults to `panel`. */ + variant?: 'panel'; + open?: never; + onOpenChange?: never; +} + +export interface AIReconciliationModalProps extends AIReconciliationPanelBaseProps { + variant: 'modal'; + /** Controls modal visibility. */ + open: boolean; + /** Modal open-state change handler. */ + onOpenChange: (open: boolean) => void; +} + +export type AIReconciliationPanelProps = + | AIReconciliationPanelVariantProps + | AIReconciliationModalProps; + // ============================================================================ // Equality helpers // ============================================================================ @@ -175,10 +188,15 @@ function normalizeString(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } -function stableStringify(value: unknown): string { +function stableStringify( + value: unknown, + seen: WeakSet = new WeakSet() +): string { if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (seen.has(value as object)) return '"[Circular]"'; + seen.add(value as object); if (Array.isArray(value)) - return '[' + value.map(stableStringify).join(',') + ']'; + return '[' + value.map((v) => stableStringify(v, seen)).join(',') + ']'; const keys = Object.keys(value as Record).sort(); return ( '{' + @@ -187,13 +205,23 @@ function stableStringify(value: unknown): string { (k) => JSON.stringify(k) + ':' + - stableStringify((value as Record)[k]) + stableStringify((value as Record)[k], seen) ) .join(',') + '}' ); } +function safeStableStringify(value: unknown): string { + try { + return stableStringify(value); + } catch { + // e.g. a getter that throws; fall back to a value-independent sentinel + // that still differs from `undefined` / `null`. + return '"[Unserializable]"'; + } +} + /** * Default equality used to filter out cosmetic-only diffs. Treats null, * undefined, and `''` as equivalent; ignores case and whitespace for strings; @@ -563,7 +591,7 @@ function AIReconciliationPanel({ effective .map( (p) => - `${p.id}\u241F${stableStringify(p.proposed)}\u241F${ + `${p.id}\u241F${safeStableStringify(p.proposed)}\u241F${ p.defaultAccepted ?? '' }\u241F${p.required ?? ''}\u241F${p.confidence ?? ''}\u241F${ p.confidenceLevel ?? '' @@ -773,7 +801,11 @@ function AIReconciliationPanel({ aria-label={allAccepted ? rejectAllLabel : acceptAllLabel} label={allAccepted ? rejectAllLabel : acceptAllLabel} /> -

    +

    {acceptedCount} of {effective.length} selected

    diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts index a07b3ba5..b8269e8a 100644 --- a/src/tailwind-preset.ts +++ b/src/tailwind-preset.ts @@ -328,6 +328,10 @@ export const miewebUISafelist = [ 'dark:bg-amber-900/40', 'dark:text-amber-200', 'dark:text-amber-300', + 'ring-1', + 'last:border-b-0', + 'border-dashed', + 'text-[11px]', ]; export interface MiewebUIPreset {