Reviews and agents
The editor draws what an agent proposed or wrote; these parts give that a React face. Asking the agent, keeping its proposals and deciding who may accept them stay with the app.
ChangeReview
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@adecore/ui';
import type { Editor } from '@adecore/editor';
import { ChangeReview, RowHost } from '@adecore/editor-react';
import { EditorFrame } from '../shared/editor-frame.tsx';
import { SAMPLE, useEditor } from '../shared/editor.ts';
const OPTIONS = { text: SAMPLE, language: 'typescript', theme: 'demo' } as const;
const SELECTED = ' for (const line of lines) {\n total += line.price * line.quantity;\n }\n';
const PROPOSAL = ' total = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);\n';
/* A proposed change to lines 6 to 8, drawn in a row under them until a person decides. */
export default function ChangeReviewDemo() {
const [rows, setRows] = useState<RowHost | null>(null);
const [decided, setDecided] = useState<string | null>(null);
const setup = useCallback((editor: Editor) => {
const host = new RowHost(editor, 'review');
host.set([{ id: 'proposal', line: 7, placement: 'below', height: 120 }]);
setRows(host);
return () => {
host.clear();
setRows(null);
};
}, []);
const { host, editor } = useEditor(OPTIONS, setup);
useSyncExternalStore(rows?.subscribe ?? noSubscription, rows?.getVersion ?? zero, zero);
useEffect(() => {
if (decided !== null) {
rows?.clear();
}
}, [decided, rows]);
const container = rows?.container('proposal');
return (
<>
<EditorFrame
host={host}
toolbar={<span className="px-1 text-xs text-text-muted">{decided ?? 'Accept or reject the proposal under line 8.'}</span>}
/>
{editor !== null &&
container !== undefined &&
decided === null &&
createPortal(
<ChangeReview
editor={editor}
selected={SELECTED}
proposal={PROPOSAL}
startLine={6}
label="Proposed change"
className="border-y border-border bg-surface"
actions={
<div className="flex gap-1 px-2 py-1">
<Button size="xs" variant="primary" onClick={() => setDecided('Accepted. The app applies the edit itself.')}>
Accept
</Button>
<Button size="xs" onClick={() => setDecided('Rejected.')}>
Reject
</Button>
</div>
}
/>,
container
)}
</>
);
}
const noSubscription = (): (() => void) => () => {};
const zero = (): number => 0;ChangeReview draws a proposed change to a stretch of the file as a diff in the editor's own font and colors: unchanged lines, removed lines faded with -, added lines with +, and the replaced words in a stronger tint. It is meant for an editor row under the stretch.
| Prop | Type | Meaning |
|---|---|---|
editor | Editor | The editor whose renderCode draws the lines |
selected | string | The text as it stands |
proposal | string | The text as proposed |
startLine | number | The one-based number of the first line; 1 by default |
label | string | The accessible name of the review |
actions | ReactNode | Drawn under the diff, such as Accept and Reject |
className | string | On the outer section |
The component accepts nothing itself. To accept, guard the stretch with a tracked range while the proposal is out, and apply the proposal over it:
const current = tracked.get();
if (current !== null && editor.textInRange(current) === selected) {
editor.applyEdits([{ range: current, text: fitReplacement(selected, proposal) }]);
}fitReplacement gives the proposal the line breaks and the trailing break of the selected text. parseAnswer takes the last closed replacement fence out of a model's Markdown answer, with the rest as explanation. See proposals.
AttributionCard
import { useCallback, useRef, useState } from 'react';
import { AGENT_COLORS, type Editor, type EditorAttributionHover } from '@adecore/editor';
import { AttributionCard } from '@adecore/editor-react';
import { EditorFrame } from '../shared/editor-frame.tsx';
import { SAMPLE, useEditor } from '../shared/editor.ts';
const OPTIONS = { text: SAMPLE, language: 'typescript', theme: 'demo' } as const;
const RUNS = {
pricing: { title: 'Pricing agent', subtitle: 'lines 4 to 10', prompt: 'Add up the price of every line of the order.', color: AGENT_COLORS[0] },
summary: { title: 'Writer', subtitle: 'lines 12 to 15', prompt: 'Describe an order in one sentence.', color: AGENT_COLORS[1] }
} as const;
/* Rest the pointer on a colored bar in the gutter; the card stays while the pointer is on it. */
export default function AttributionCardDemo() {
const [hover, setHover] = useState<EditorAttributionHover | null>(null);
const held = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const setup = useCallback((editor: Editor) => {
editor.setAttributionMarks([
{ id: 'pricing', startLine: 4, endLine: 10, color: RUNS.pricing.color },
{ id: 'summary', startLine: 12, endLine: 15, color: RUNS.summary.color }
]);
const stop = editor.onAttributionHover((next) => {
clearTimeout(timer.current);
if (next !== null) {
setHover(next);
return;
}
// A moment to reach the card before it goes.
timer.current = setTimeout(() => {
if (!held.current) {
setHover(null);
}
}, 250);
});
return () => {
clearTimeout(timer.current);
stop();
};
}, []);
const { host } = useEditor(OPTIONS, setup);
const run = hover === null ? null : RUNS[hover.id as keyof typeof RUNS];
return (
<>
<EditorFrame host={host} />
{hover !== null && run !== null && (
<AttributionCard
rect={hover.rect}
title={run.title}
subtitle={run.subtitle}
prompt={run.prompt}
color={run.color}
onHold={(inside) => {
held.current = inside;
if (!inside) {
setHover(null);
}
}}
/>
)}
</>
);
}AttributionCard is a card about a run of lines an agent wrote, placed by the rect that onAttributionHover reports.
| Prop | Meaning |
|---|---|
rect | Where the bar is, in page pixels |
title, subtitle | Such as the agent's name and what it did |
metadata | A line of the app's own under the title |
prompt | What the agent was asked, quoted |
color, icon | The agent's color and an icon; a dot in that color by default |
actions | Buttons under the card |
onHold(inside) | The pointer entered or left the card, to keep it open meanwhile |
Rows and actions in React
The editor makes a widget row's element again whenever the row scrolls back into view. RowHost keeps track of those elements so React can portal into them:
const rows = new RowHost(editor, 'review');
rows.set([{ id: 'proposal', line: 7, placement: 'below', height: 120 }]);
// In the component:
useSyncExternalStore(rows.subscribe, rows.getVersion);
const container = rows.container('proposal');
return container === undefined ? null : createPortal(<ChangeReview editor={editor} {...review} />, container);set(rows) replaces the owner's rows; the same ids, lines and placements again change nothing, so a new height alone does not reach the editor. clear() removes them. Since the element comes and goes, keep state outside it.
LineActionHost(editor, owner) does the same for line actions: set takes { id, line }s, and an element lives as long as its action, so React state in it survives.
The editor has one set of line highlights, and a review and a conflict may both want some. highlightLayers(editor) returns the HighlightLayers of an editor: set(owner, provider) takes a function that returns the owner's highlights as they stand now, and set(owner, null) removes them. Every change reads all owners again, so one owner never brings back the old lines of another.