DiffReview
Review the files an agent changed before applying them. DiffFileList and DiffFileView sit side by side in MasterDetail from 900px of width; narrower, the diff replaces the list. Move between files with Previous/Next or j/k while focus is inside the review, mark files as viewed with a progress count, and accept or reject each file or all of them; the buttons appear only for the callbacks you pass and show pending state. DiffReviewModal shows the same review in a modal that fills small screens.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Wide
Narrow
Read-only, split view
Modal
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
diff/DiffReview/DiffReview.tsx
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Box,
Button,
Checkbox,
Divider,
EmptyState,
Group,
Progress,
SegmentedControl,
Select,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { getHotkeyHandler, useElementSize } from '@mantine/hooks';
import {
IconAlertCircle,
IconCheck,
IconChevronDown,
IconChevronUp,
IconFileDiff,
IconX,
} from '@tabler/icons-react';
import { MasterDetail } from '../../primitives/MasterDetail/MasterDetail';
import { ShortcutHint } from '../../primitives/ShortcutHint/ShortcutHint';
import { cx } from '../../utils/cx';
import { DiffFileList, type DiffFileListView } from '../DiffFileList/DiffFileList';
import { DiffFileView, type DiffViewMode } from '../DiffFileView/DiffFileView';
import { buildFileTree, flattenFileTree } from '../file-tree';
import { resolveDiffLabels } from '../labels';
import type { SyntaxHighlighter } from '../../utils/highlighter';
import type { DiffLabels, DiffSource, FileChange, FileDecision } from '../types';
import { usePendingActions } from '../../hooks/use-pending-actions';
import classes from './DiffReview.module.css';
export type FileChangeAction = (change: FileChange) => void | Promise<void>;
export interface DiffReviewProps {
/** Changed files in review order, ignored when `sources` is set */
changes?: FileChange[];
/** Alternative change sets, for example uncommitted changes and each agent turn, switched above the list */
sources?: DiffSource[];
/** Id of the shown source, controlled */
sourceId?: string;
/** Initial source id when uncontrolled, the first source by default */
defaultSourceId?: string;
/** Called when another source is picked; the first file of that source is opened */
onSourceChange?: (id: string) => void;
/** Path of the open file, controlled */
selectedPath?: string | null;
/** Initial open file when uncontrolled, the first file by default */
defaultSelectedPath?: string | null;
/** Called when another file is opened */
onSelectedPathChange?: (path: string | null) => void;
/** Paths marked as viewed, controlled */
viewedPaths?: string[];
/** Initial viewed paths when uncontrolled */
defaultViewedPaths?: string[];
/** Called when a file is marked or unmarked as viewed */
onViewedPathsChange?: (paths: string[]) => void;
/** Accept or reject decision per path, shown in the list and on the buttons */
decisions?: Record<string, FileDecision>;
/** Accepts one file, renders the Accept button when set */
onAccept?: FileChangeAction;
/** Rejects one file, renders the Reject button when set */
onReject?: FileChangeAction;
/** Accepts every file, renders the Accept all button when set */
onAcceptAll?: () => void | Promise<void>;
/** Rejects every file, renders the Reject all button when set */
onRejectAll?: () => void | Promise<void>;
/** Initial file list layout, `list` by default; Prev/Next follow the order of the shown layout */
defaultView?: DiffFileListView;
/** Colors the diff with this highlighter */
highlighter?: SyntaxHighlighter;
/** Initial diff layout, `unified` by default */
defaultMode?: DiffViewMode;
/** Content at the start of the first row, on the same inner edge as the controls, for example a panel title */
header?: React.ReactNode;
/** Lighter controls for side panels: ghost buttons and the file search behind an icon; follows the width when omitted */
compact?: boolean;
/** Marks the file search with `data-autofocus` so an enclosing Drawer or Modal focuses it, `true` by default */
searchAutofocus?: boolean;
/** Enables `j`/`k` to move between files while focus is inside the review, `true` by default */
withHotkeys?: boolean;
/** Shows skeleton rows in the file list */
loading?: boolean;
/** Error message shown instead of the file list */
error?: React.ReactNode;
/** Called by the retry button of the error alert */
onRetry?: () => void;
/** File list width in px when wide, `320` by default */
listWidth?: number;
/** Component width in px from which the list and the diff sit side by side, `900` by default */
breakpoint?: number;
/** Overrides of the default English labels */
labels?: Partial<DiffLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
const NON_TYPING_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset']);
function isTypingTarget(target: HTMLElement) {
if (target.isContentEditable || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return true;
}
return target.tagName === 'INPUT' && !NON_TYPING_INPUTS.has((target as HTMLInputElement).type);
}
const EMPTY_CHANGES: FileChange[] = [];
const SEGMENTED_SOURCES_WIDTH = 560;
/** Multi-file change review: file list and diff, file navigation with j/k, viewed marks and accept/reject */
export const DiffReview = memo(function DiffReview({
changes: changesProp,
sources,
sourceId: sourceIdProp,
defaultSourceId,
onSourceChange,
selectedPath: selectedPathProp,
defaultSelectedPath,
onSelectedPathChange,
viewedPaths: viewedPathsProp,
defaultViewedPaths = [],
onViewedPathsChange,
decisions,
onAccept,
onReject,
onAcceptAll,
onRejectAll,
defaultView = 'list',
defaultMode,
highlighter,
header,
compact: compactProp,
searchAutofocus = true,
withHotkeys = true,
loading,
error,
onRetry,
listWidth = 320,
breakpoint = 900,
labels: labelsProp,
className,
style,
}: DiffReviewProps) {
const [innerSourceId, setInnerSourceId] = useState(defaultSourceId ?? sources?.[0]?.id);
const activeSourceId = sourceIdProp ?? innerSourceId;
const activeSource = sources?.find((source) => source.id === activeSourceId) ?? sources?.[0];
const changes = useMemo(
() => activeSource?.changes ?? changesProp ?? EMPTY_CHANGES,
[activeSource, changesProp]
);
const { ref: rootRef, width } = useElementSize<HTMLDivElement>();
const measuring = width === 0;
const compact = compactProp ?? width < breakpoint;
const labels = useMemo<DiffLabels>(() => resolveDiffLabels(labelsProp), [labelsProp]);
const [innerSelected, setInnerSelected] = useState<string | null>(
defaultSelectedPath !== undefined ? defaultSelectedPath : (changes[0]?.path ?? null)
);
const [innerViewed, setInnerViewed] = useState(defaultViewedPaths);
const [detailOpen, setDetailOpen] = useState(false);
const [view, setView] = useState<DiffFileListView>(defaultView);
const [mode, setMode] = useState<DiffViewMode>(defaultMode ?? 'unified');
const actions = usePendingActions();
const topRef = useRef<HTMLDivElement>(null);
const viewedPaths = viewedPathsProp ?? innerViewed;
const viewed = useMemo(() => new Set(viewedPaths), [viewedPaths]);
const order = useMemo(
() => (view === 'tree' ? flattenFileTree(buildFileTree(changes)) : changes),
[view, changes]
);
const innerSelectedMissing =
(innerSelected !== null || defaultSelectedPath === undefined) &&
!order.some((change) => change.path === innerSelected);
const selectedPath =
selectedPathProp !== undefined
? selectedPathProp
: innerSelectedMissing
? (order[0]?.path ?? null)
: innerSelected;
const index = order.findIndex((change) => change.path === selectedPath);
const selected = index === -1 ? undefined : order[index];
useEffect(() => {
topRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [selectedPath]);
const select = (path: string | null) => {
setInnerSelected(path);
onSelectedPathChange?.(path);
};
const changeSource = (id: string | null) => {
const next = sources?.find((source) => source.id === id);
if (!next || next.id === activeSource?.id) {
return;
}
setInnerSourceId(next.id);
onSourceChange?.(next.id);
select(next.changes[0]?.path ?? null);
setDetailOpen(false);
};
const sourceOptions = sources?.map((source) => ({
value: source.id,
label: `${source.label} (${source.changes.length})`,
}));
const sourceSwitcher =
sourceOptions && sourceOptions.length > 1 ? (
width >= SEGMENTED_SOURCES_WIDTH && sourceOptions.length <= 4 ? (
<SegmentedControl
className={classes.measured}
data-measuring={width === 0 || undefined}
size="xs"
aria-label={labels.changesSource}
value={activeSource?.id}
onChange={changeSource}
data={sourceOptions}
/>
) : (
<Select
className={classes.measured}
data-measuring={width === 0 || undefined}
size="xs"
w={180}
aria-label={labels.changesSource}
allowDeselect={false}
value={activeSource?.id ?? null}
onChange={changeSource}
data={sourceOptions}
/>
)
) : null;
const go = (step: 1 | -1) => {
if (order.length === 0) {
return;
}
const next = index === -1 ? (step === 1 ? 0 : order.length - 1) : index + step;
if (next >= 0 && next < order.length) {
select(order[next].path);
}
};
const hotkeys = getHotkeyHandler([
['j', () => go(1)],
['k', () => go(-1)],
]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!withHotkeys || isTypingTarget(event.target as HTMLElement)) {
return;
}
hotkeys(event);
};
const toggleViewed = (path: string, checked: boolean) => {
const next = checked
? [...viewedPaths.filter((item) => item !== path), path]
: viewedPaths.filter((item) => item !== path);
setInnerViewed(next);
onViewedPathsChange?.(next);
};
const viewedCount = changes.filter((change) => viewed.has(change.path)).length;
const bulkPending = actions.isPending('acceptAll') || actions.isPending('rejectAll');
const fileActions = selected && (
<Group gap="xs" wrap="nowrap">
<Checkbox
size="xs"
label={labels.viewed}
checked={viewed.has(selected.path)}
onChange={(event) => toggleViewed(selected.path, event.currentTarget.checked)}
/>
{onReject && (
<Button
size="compact-xs"
color="red"
variant={decisions?.[selected.path] === 'rejected' ? 'filled' : 'light'}
leftSection={<IconX size={12} />}
loading={actions.isPending(`reject:${selected.path}`)}
disabled={bulkPending}
onClick={() => actions.run(`reject:${selected.path}`, () => onReject(selected))}
>
{labels.reject}
</Button>
)}
{onAccept && (
<Button
size="compact-xs"
color="green"
variant={decisions?.[selected.path] === 'accepted' ? 'filled' : 'light'}
leftSection={<IconCheck size={12} />}
loading={actions.isPending(`accept:${selected.path}`)}
disabled={bulkPending}
onClick={() => actions.run(`accept:${selected.path}`, () => onAccept(selected))}
>
{labels.accept}
</Button>
)}
</Group>
);
return (
<Stack
gap={0}
ref={rootRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(classes.root, className)}
style={style}
>
{header !== undefined && !compact && (
<Group
gap="xs"
wrap="nowrap"
className={cx(classes.measured, classes.inset, classes.panelHeader)}
data-measuring={(compactProp === undefined && measuring) || undefined}
>
{header}
</Group>
)}
<Group
gap="xs"
py={compact ? 4 : 6}
justify="space-between"
wrap="wrap"
className={cx(
classes.measured,
classes.inset,
compact && header !== undefined && classes.panelHeader
)}
data-measuring={(compactProp === undefined && measuring) || undefined}
data-compact={compact || undefined}
>
{compact && header !== undefined && (
<Box flex={1} miw={0}>
{header}
</Box>
)}
{sourceSwitcher}
<Group gap={compact ? 4 : 'xs'} wrap="nowrap">
<Tooltip label={<ShortcutHint label={labels.previousFile} keys="k" />} withArrow>
<ActionIcon
variant={compact ? 'subtle' : 'default'}
color={compact ? 'gray' : undefined}
className={compact ? classes.ghostNav : undefined}
size="sm"
aria-label={labels.previousFile}
disabled={index <= 0}
onClick={() => go(-1)}
>
<IconChevronUp size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={<ShortcutHint label={labels.nextFile} keys="j" />} withArrow>
<ActionIcon
variant={compact ? 'subtle' : 'default'}
color={compact ? 'gray' : undefined}
className={compact ? classes.ghostNav : undefined}
size="sm"
aria-label={labels.nextFile}
disabled={index === order.length - 1 || order.length === 0}
onClick={() => go(1)}
>
<IconChevronDown size={14} />
</ActionIcon>
</Tooltip>
<Stack gap={2} miw={0}>
<Text size="xs" c="dimmed" className={classes.counter} aria-live="polite">
{labels.viewedCount(viewedCount, changes.length)}
</Text>
<Progress
size={compact ? 2 : 3}
w={compact ? 64 : 96}
value={changes.length ? (viewedCount / changes.length) * 100 : 0}
aria-label={labels.viewedCount(viewedCount, changes.length)}
/>
</Stack>
</Group>
{(onAcceptAll || onRejectAll) && (
<Group gap="xs" wrap="nowrap">
{onRejectAll && (
<Button
size={compact ? 'compact-xs' : 'xs'}
variant={compact ? 'subtle' : 'default'}
color={compact ? 'gray' : undefined}
loading={actions.isPending('rejectAll')}
disabled={actions.isPending('acceptAll') || changes.length === 0}
onClick={() => actions.run('rejectAll', onRejectAll)}
>
{labels.rejectAll}
</Button>
)}
{onAcceptAll && (
<Button
size={compact ? 'compact-xs' : 'xs'}
variant={compact ? 'light' : undefined}
loading={actions.isPending('acceptAll')}
disabled={actions.isPending('rejectAll') || changes.length === 0}
onClick={() => actions.run('acceptAll', onAcceptAll)}
>
{labels.acceptAll}
</Button>
)}
</Group>
)}
</Group>
{actions.error && (
<Alert
mx="sm"
mb="xs"
color="red"
variant="light"
icon={<IconAlertCircle size={16} />}
withCloseButton
closeButtonLabel={labels.dismiss}
onClose={() => actions.clearError()}
>
{actions.error}
</Alert>
)}
{header === undefined && <Divider />}
<Box className={classes.body}>
<MasterDetail
listWidth={listWidth}
breakpoint={breakpoint}
labels={{ back: labels.back }}
onBack={() => setDetailOpen(false)}
detailOpened={detailOpen}
emptyDetail={
<EmptyState h="100%" p="xl" icon={<IconFileDiff />} title={labels.selectFile} />
}
list={
<Box py="xs" className={classes.inset}>
<DiffFileList
changes={changes}
compact={compact}
searchAutofocus={searchAutofocus}
view={view}
onViewChange={setView}
selectedPath={selectedPath}
onSelect={(change) => {
select(change.path);
setDetailOpen(true);
}}
viewedPaths={viewedPaths}
decisions={decisions}
loading={loading}
error={error}
onRetry={onRetry}
labels={labelsProp}
/>
</Box>
}
detail={
selected ? (
<Box py="xs" className={classes.inset} ref={topRef}>
<DiffFileView
change={selected}
mode={mode}
onModeChange={setMode}
headerActions={fileActions}
highlighter={highlighter}
labels={labelsProp}
/>
</Box>
) : null
}
/>
</Box>
</Stack>
);
});
DiffReview.displayName = 'DiffReview';
API reference
Prop
Type
Required
changes
FileChange[]
No
Changed files in review order, ignored when `sources` is set
sources
DiffSource[]
No
Alternative change sets, for example uncommitted changes and each agent turn, switched above the list
sourceId
string
No
Id of the shown source, controlled
defaultSourceId
string
No
Initial source id when uncontrolled, the first source by default
onSourceChange
(id: string) => void
No
Called when another source is picked; the first file of that source is opened
selectedPath
string | null
No
Path of the open file, controlled
defaultSelectedPath
string | null
No
Initial open file when uncontrolled, the first file by default
onSelectedPathChange
(path: string | null) => void
No
Called when another file is opened
viewedPaths
string[]
No
Paths marked as viewed, controlled
defaultViewedPaths
string[]
No
Initial viewed paths when uncontrolled
onViewedPathsChange
(paths: string[]) => void
No
Called when a file is marked or unmarked as viewed
decisions
Record<string, FileDecision>
No
Accept or reject decision per path, shown in the list and on the buttons
onAccept
FileChangeAction
No
Accepts one file, renders the Accept button when set
onReject
FileChangeAction
No
Rejects one file, renders the Reject button when set
onAcceptAll
() => void | Promise<void>
No
Accepts every file, renders the Accept all button when set
onRejectAll
() => void | Promise<void>
No
Rejects every file, renders the Reject all button when set
defaultView
DiffFileListView
No
Initial file list layout, `list` by default; Prev/Next follow the order of the shown layout
highlighter
SyntaxHighlighter
No
Colors the diff with this highlighter
defaultMode
DiffViewMode
No
Initial diff layout, `unified` by default
header
React.ReactNode
No
Content at the start of the first row, on the same inner edge as the controls, for example a panel title
compact
boolean
No
Lighter controls for side panels: ghost buttons and the file search behind an icon; follows the width when omitted
searchAutofocus
boolean
No
Marks the file search with `data-autofocus` so an enclosing Drawer or Modal focuses it, `true` by default
withHotkeys
boolean
No
Enables `j`/`k` to move between files while focus is inside the review, `true` by default
loading
boolean
No
Shows skeleton rows in the file list
error
React.ReactNode
No
Error message shown instead of the file list
onRetry
() => void
No
Called by the retry button of the error alert
listWidth
number
No
File list width in px when wide, `320` by default
breakpoint
number
No
Component width in px from which the list and the diff sit side by side, `900` by default
labels
Partial<DiffLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element