Editor
The editor is built on Lexical 0.39. Documents are stored as a node tree. Agent edits show up as pending suggestions first; you accept or reject each one before it changes the document. Template sections can be locked so agents can’t write to them.
Components
| Area | What it does |
|---|---|
EditorCore | Mounts Lexical, tracks selection, dispatches commands. |
NoteEditorV2 | Sets up the composer, owns the live Lexical editor instance, and bridges debounced snapshots back to app state. |
TemplateManager | Holds template anchors and fields. Blocks writes to locked regions. |
SuggestionOverlay | Renders pending agent diffs in the editor and routes review to the revisions surface. |
SyncBridge | Persists debounced plain-text and Lexical JSON snapshots, with blur/unload flushes. |
Runtime Topology
Core Rules
- All document mutations go through
editor.update(). There is no other way to write to the document, so every change is transactional and observable. - Lexical is the live typing source of truth. React note state and persisted JSON are snapshots, not keystroke-by-keystroke control loops.
- Agents and automation cannot modify locked template anchors. Only a user edit can.
- Each suggestion is tagged with the revision it was generated against. If the user edits in between, the suggestion’s range is remapped onto the new revision, or marked stale if remapping can’t resolve it.
- Undo/redo only covers changes that actually landed. Rejected and stale suggestions never wrote to the document, so they don’t appear in history.
- Every applied change, whether from a user or an agent, emits the same change event. Sync, logging, and other subscribers read from one stream.
Document Model
Documents are serialized as node trees, not HTML strings:
interface SerializedEditorState {
root: {
type: "root";
version: number;
children: SerializedNode[];
direction: "ltr" | "rtl" | null;
};
}
interface SerializedNode {
type: string;
version: number;
children?: SerializedNode[];
text?: string;
format?: number;
detail?: number;
mode?: "normal" | "segmented" | "token";
}Node Types
class RosettaTextNode extends TextNode {
__format: number;
}
class RosettaParagraphNode extends ParagraphNode {
__indent: number;
}
class AnchorNode extends ElementNode {
__label: string;
__isLocked: boolean;
}
class FieldNode extends DecoratorNode<JSX.Element> {
__fieldType: "SELECT" | "MULTISELECT" | "DYNAMIC_SELECT";
__options: string[];
}Change Tracking
Rosetta keeps two separate records of changes:
- Lexical history stack: what undo/redo walks through. Only contains changes that were applied.
- Operational log: what sync and agent bookkeeping read from. Contains every transaction with its source, revision, and ranges.
Transaction Shape
type ChangeSource = "user" | "ai" | "template" | "sync";
type ChangeKind = "insert" | "replace" | "delete" | "format";
interface ChangeTransaction {
id: string;
revision: number;
parentRevision: number;
source: ChangeSource;
kind: ChangeKind;
createdAt: number;
ranges: Array<{
start: number;
end: number;
beforeText: string;
afterText: string;
anchorId?: string;
}>;
metadata?: {
suggestionId?: string;
agent?: "chat" | "shorthander" | "reasoner" | "reformatter" | "citations" | "ingester";
batched?: boolean;
};
}Keystroke-To-Commit Flow
During typing, Rosetta computes one derived editor snapshot per Lexical update. That snapshot carries the plain text, selection offsets, cursor position, slash-command context, SmartPhrase context, and context-menu selection geometry. JSON serialization is deferred until the persistence debounce or an explicit flush point such as blur, unload, save, or note switch.
Diff Strategy
The diff runs in three passes. Each pass only runs if the previous one didn’t produce a usable result:
- Node-level: compare subtrees by identity. Unchanged subtrees are skipped.
- Token-level: diff by sentence or field chunk. Most edits stop here.
- Character-level: used when token precision drops below 98%. Needed so overlays can highlight the exact changed characters.
function computeRanges(prev: string, next: string): RangeDelta[] {
const tokenPass = diffByTokens(prev, next);
if (tokenPass.precision >= 0.98) return tokenPass.ranges;
return diffByCharacters(prev, next).ranges;
}Range Remapping
A suggestion points at specific character offsets. If the user types before the suggestion is applied, those offsets now point at the wrong place. The fix is to shift each offset by the net length change of every user edit that happened at or before it:
function remapRange(range: { start: number; end: number }, deltas: RangeDelta[]) {
let { start, end } = range;
for (const delta of deltas) {
if (delta.pos <= start) start += delta.netLength;
if (delta.pos < end) end += delta.netLength;
}
return { start, end };
}Overlap and Conflict Rules
Agent Suggestion Lifecycle
Suggestion Object
type SuggestionStatus =
| "pending"
| "focused"
| "accepted"
| "rejected"
| "stale"
| "superseded";
interface AISuggestion {
id: string;
revision: number;
type: "replace" | "insert" | "delete";
start: number;
end: number;
replacementText: string;
reasoning: string;
confidence: number;
status: SuggestionStatus;
}Suggestion State Machine
Safe Apply Logic
function applySuggestion(suggestion: AISuggestion) {
editor.update(() => {
if (isLockedRange(suggestion.start, suggestion.end)) return;
const remapped = remapSuggestionAgainstLatestRevision(suggestion);
if (!remapped) {
markSuggestion(suggestion.id, "stale");
return;
}
replaceText(remapped.start, remapped.end, remapped.replacementText);
appendChangeTransaction({
source: "ai",
kind: suggestion.type === "insert" ? "insert" : "replace",
metadata: { suggestionId: suggestion.id }
});
markSuggestion(suggestion.id, "accepted");
});
}Visual Indicators
| Type | Color | Behavior |
|---|---|---|
| Insert | Green | Preview as additive text |
| Replace | Yellow | Original highlighted with replacement preview |
| Delete | Red | Original shown with remove indicator |
Template Anchors
interface TemplateAnchor {
id: string;
label: string;
isLocked: boolean;
nodeKey: string;
start: number;
end: number;
}Guard Rails
- Agents cannot modify locked anchors.
- Bulk accept skips suggestions touching locked anchors.
- Template updates can move anchors but cannot silently unlock them.
- Anchor deletion requires explicit user confirmation.
Anchor Navigation
| Shortcut | Action |
|---|---|
Ctrl + ] | Next anchor |
Ctrl + [ | Previous anchor |
Ctrl + Enter | Fill current anchor and advance |
Template Fields
SELECT single choice:
{{SELECT: medication | aspirin, clopidogrel, warfarin}}MULTISELECT multiple values:
{{MULTISELECT: symptoms | chest pain, dyspnea, diaphoresis}}DYNAMIC_SELECT options resolved from query:
{{DYNAMIC_SELECT: drug | query: medications for hypertension}}Field Node Rendering
class FieldNode extends DecoratorNode<JSX.Element> {
decorate(): JSX.Element {
return (
<FieldComponent
type={this.__fieldType}
options={this.__options}
onSelect={this.handleSelect}
/>
);
}
}SmartPhrases
Expansion rules for high-frequency sections:
const smartPhrases: SmartPhrase[] = [
{ trigger: ".cc", expansion: "Chief Complaint:\n" },
{ trigger: ".hpi", expansion: "History of Present Illness:\n" },
{ trigger: ".pe", expansion: "Physical Examination:\n" },
{ trigger: ".ap", expansion: "Assessment & Plan:\n" }
];Expansion is treated as a user transaction. Pending agent suggestions touching the trigger span are remapped; if remap fails, they are marked stale.
Plugin Order
<LexicalComposer initialConfig={config}>
<RichTextPlugin />
<HistoryPlugin />
<RosettaStateSyncPlugin />
<TemplateLockPlugin />
<TemplateReconciliationPlugin />
<EditorCommandPlugin />
<ClinicalHighlightPlugin />
<IndentGuidesPlugin />
</LexicalComposer>RosettaStateSyncPlugin owns the external load path and the shared derived editor snapshot. EditorCommandPlugin owns keyboard commands, suggestion commands, and template-field commands. Clinical highlighting and indent guides run after editor mutations, but both are scheduled outside the immediate typing path.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl/Cmd + B | Bold |
Ctrl/Cmd + I | Italic |
Ctrl/Cmd + U | Underline |
Ctrl/Cmd + Z | Undo |
Ctrl/Cmd + Shift + Z | Redo |
Tab | Accept focused suggestion |
Esc | Reject focused suggestion |
Ctrl/Cmd + Shift + A | Accept all pending |
Ctrl/Cmd + Shift + X | Reject all pending |
Ctrl + ] | Next anchor |
Ctrl + [ | Previous anchor |
Ctrl + Enter | Fill anchor and advance |
Performance
- Keep JSON persistence off the keystroke path. Typing updates the Lexical tree immediately; persisted JSON is generated after the debounce or at a flush boundary.
- Compute editor-derived state once. Plain text, selection offsets, cursor position, slash commands, SmartPhrases, and context-menu selection data share one update listener.
- Cache clinical tokenization. The clinical highlighter reuses matches for unchanged text nodes and ignores selection-only updates.
- Schedule visual work. Clinical highlights, indent guides, minimap sampling, and row metrics run through timers, animation frames, and idle callbacks.
- Virtualize layout chrome. The gutter renders only visible line numbers plus spacers, and line metrics cache per-block row measurements.
- Debounce sync writes. Group rapid keystrokes into one transaction before sending. One PATCH per burst, not one per keypress.