Skip to content

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 ​

tsx
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.

PropTypeMeaning
editorEditorThe editor whose renderCode draws the lines
selectedstringThe text as it stands
proposalstringThe text as proposed
startLinenumberThe one-based number of the first line; 1 by default
labelstringThe accessible name of the review
actionsReactNodeDrawn under the diff, such as Accept and Reject
classNamestringOn 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:

ts
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 ​

tsx
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.

PropMeaning
rectWhere the bar is, in page pixels
title, subtitleSuch as the agent's name and what it did
metadataA line of the app's own under the title
promptWhat the agent was asked, quoted
color, iconThe agent's color and an icon; a dot in that color by default
actionsButtons 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:

tsx
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.

Licenses and third-party notices are listed in each package.