Skip to Content
EditorEditor Guide

Editor

The editor is built on Lexical 0.39. A note is stored as a tree of editor nodes. Agent edits appear as pending suggestions first. You accept or reject each suggestion before it changes the note. Locking a template section blocks agents from writing to it.

Components

AreaWhat it does
EditorCoreMounts Lexical, tracks selection, and dispatches commands.
LexicalNoteEditorThe active editor runtime: sets up the composer, owns the live Lexical editor instance, and bridges debounced snapshots back to app state. NoteEditorV2 remains as a compatibility wrapper around it.
TemplateManagerHolds template anchors and fields. Blocks writes to locked regions.
SuggestionOverlayRenders pending agent diffs in the editor and routes review to the revisions surface.
SyncBridgePersists debounced plain text and Lexical JSON snapshots, with blur and unload flushes.

Core Rules

  1. All document mutations go through editor.update(). Every write is transactional and observable.
  2. Lexical is the live typing source of truth. React note state and persisted JSON are snapshots.
  3. Agents and automation cannot modify locked template anchors.
  4. 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.
  5. Undo/redo only covers changes that actually landed. Rejected and stale suggestions never wrote to the document.
  6. 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:

Serialized document
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

Custom nodes
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

Changes land in two separate records:

  • 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

ChangeTransaction
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, one editor snapshot is computed per Lexical update.

Diffs

The old note and the new note are compared in three passes. Each pass only runs if the previous one did not produce a reliable result:

  1. Nodes: compare editor subtrees by identity. If a whole paragraph or template block did not change, the pass skips it.
  2. Tokens: compare sentence sized chunks or template chunks. Most clinical edits stop here because this is accurate enough for review.
  3. Characters: compare individual characters when token precision drops below 98%. This lets the overlay highlight the exact text that changed.
computeRanges
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 can point at the wrong place. Each offset is shifted by the net length change of every user edit that happened at or before it:

remapRange
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

AISuggestion
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

applySuggestion
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

TypeColorBehavior
InsertGreenPreview as additive text
ReplaceYellowOriginal highlighted with replacement preview
DeleteRedOriginal shown with remove indicator

Template Anchors

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

Template Fields

SELECT single choice:

SELECT field
{{SELECT: medication | aspirin, clopidogrel, warfarin}}

MULTISELECT multiple values:

MULTISELECT field
{{MULTISELECT: symptoms | chest pain, dyspnea, diaphoresis}}

DYNAMIC_SELECT options resolved from query:

DYNAMIC_SELECT field
{{DYNAMIC_SELECT: drug | query: medications for hypertension}}

Field Node Rendering

FieldNode
class FieldNode extends DecoratorNode<JSX.Element> { decorate(): JSX.Element { return ( <FieldComponent type={this.__fieldType} options={this.__options} onSelect={this.handleSelect} /> ); } }

SmartPhrases

Expansion rules for common note sections:

Smart phrases
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, and if remap fails they are marked stale.

Plugin Order

Plugin order
<LexicalComposer initialConfig={config}> <RichTextPlugin /> <HistoryPlugin /> <RosettaStateSyncPlugin /> <TemplateLockPlugin /> <TemplateReconciliationPlugin /> <EditorCommandPlugin /> <ClinicalHighlightPlugin /> <IndentGuidesPlugin /> </LexicalComposer>

RosettaStateSyncPlugin owns the external load path and the shared editor snapshot. EditorCommandPlugin owns keyboard commands, suggestion commands, and template commands. Clinical highlighting and indent guides run after editor mutations, but both are scheduled outside the immediate typing path.