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. Template sections can be locked so agents cannot write to them.
Components
| Area | What it does |
|---|---|
EditorCore | Mounts Lexical, tracks selection, and dispatches commands. |
LexicalNoteEditor | The 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. |
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 and unload flushes. |
Data Flow
User input enters EditorCore, updates the Lexical state, and emits change pipeline events. The pipeline updates undo/redo history, the operational change log, and the agent suggestion queue. The log feeds local storage and sync snapshots, while queued suggestions render in the overlay until the user accepts or rejects them.
Core Rules
- All document mutations go through
editor.update(). Every write is transactional and observable. - Lexical is the live typing source of truth. React note state and persisted JSON are snapshots.
- 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:
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 waits until the persistence debounce or an explicit flush point such as blur, unload, save, or note switch.
Diffs
Rosetta compares the old note and the new note in three passes. Each pass only runs if the previous one did not produce a reliable result:
- Nodes: compare editor subtrees by identity. If a whole paragraph or template block did not change, Rosetta skips it.
- Tokens: compare sentence sized chunks or template chunks. Most clinical edits stop here because this is accurate enough for review.
- Characters: compare individual characters when token precision drops below 98%. This lets the overlay highlight the exact text that changed.
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. Rosetta shifts 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.
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 common note 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, and 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 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 + S | Save the current note (or template, in the template editor) |
Ctrl/Cmd + W | Close the current note tab |
Ctrl/Cmd + Z | Undo |
Ctrl/Cmd + Shift + Z | Redo |
Tab / Shift + Tab | Indent / outdent |
Enter or Tab | Accept a smart phrase expansion |
Esc | Dismiss a smart phrase expansion |