@@ -43,16 +43,15 @@ interface RichMarkdownEditorProps {
4343
4444/**
4545 * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files — a single editing surface
46- * (markdown transformed inline as you type), no raw/preview split. Owns the file lifecycle through a
47- * single {@link useEditableFileContent} engine: while agent output streams in ( and during the
48- * post-stream reconcile) it shows the read-only { @link PreviewPanel} with autosave disabled, so the
49- * editor never races the agent's server-side write. Once content is loaded and settled it mounts the
50- * actual editor .
46+ * (markdown transformed inline as you type), no raw/preview split and no separate streaming preview.
47+ * Owns the file lifecycle through a single {@link useEditableFileContent} engine, and the TipTap
48+ * editor is the ONLY thing the user ever sees: while agent output streams in it renders that content
49+ * read-only (synced per chunk), then the same editor instance becomes editable once the stream
50+ * settles — so the stream→edit transition has no renderer swap or flash .
5151 *
52- * The editor is mounted only once content is ready, and is keyed by file id — so the loaded markdown
53- * is the editor's *initial* `content` (parsed at create time), not pushed in by a sync effect. That
54- * keeps it robust to TipTap's strict-mode/SSR instance lifecycle: there is no content-sync effect to
55- * race, so a freshly created (or strict-mode-recreated) editor is always born with the right document.
52+ * The editor is keyed by file id (+ streaming context). A file opened outside a stream uses the plain
53+ * create-time initial-content model (no sync). See {@link LoadedRichMarkdownEditor} for the
54+ * read-only-stream → editable hand-off.
5655 */
5756export const RichMarkdownEditor = memo ( function RichMarkdownEditor ( {
5857 file,
@@ -126,6 +125,22 @@ interface LoadedRichMarkdownEditorProps {
126125 onSaveShortcut : ( ) => Promise < void >
127126}
128127
128+ interface SettledContent {
129+ frontmatter : string
130+ verdict : boolean
131+ }
132+
133+ /**
134+ * Lock the round-trip verdict + frontmatter on the content the editor "opens" with — once, at mount
135+ * for a settled file or at the moment a stream settles. A round-trip-unsafe document (raw HTML,
136+ * footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a safe one stays editable. Never
137+ * re-derived: a dirty document is safe by construction (the editor only emits safe markdown), so
138+ * flipping editability off mid-edit would only strand edits.
139+ */
140+ function lockSettled ( content : string ) : SettledContent {
141+ return { frontmatter : splitFrontmatter ( content ) . frontmatter , verdict : isRoundTripSafe ( content ) }
142+ }
143+
129144/**
130145 * The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
131146 * output streams in ({@link streaming}) it renders that content read-only and re-syncs each chunk;
@@ -149,21 +164,16 @@ export function LoadedRichMarkdownEditor({
149164 // content until the stream settles; otherwise it uses the plain create-time initial-content model.
150165 const streamingAtMountRef = useRef ( streaming )
151166
152- // The round-trip verdict + frontmatter, locked once on the content the editor "opens" with — at
153- // mount for a settled file, or at the moment the stream settles for a streamed one. A round-trip-
154- // unsafe document (raw HTML, footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a
155- // safe one is editable. Once locked it is never re-derived: a dirty document is safe by construction
156- // (the editor only emits safe markdown), so flipping editability off mid-edit would strand edits.
157- const settledRef = useRef < { frontmatter : string ; verdict : boolean } | null > ( null )
167+ // The verdict + frontmatter locked via {@link lockSettled} — at mount for a settled file, or at the
168+ // moment a stream settles (in the effect below). Null until then; reads default to read-only.
169+ const settledRef = useRef < SettledContent | null > ( null )
158170 if ( ! streamingAtMountRef . current && settledRef . current === null ) {
159- settledRef . current = {
160- frontmatter : splitFrontmatter ( content ) . frontmatter ,
161- verdict : isRoundTripSafe ( content ) ,
162- }
171+ settledRef . current = lockSettled ( content )
163172 }
164173 const isEditable = canEdit && ! streaming && ( settledRef . current ?. verdict ?? false )
165174
166- // The body that seeds the editor at create time — empty when streaming (filled by the sync effect).
175+ // The body that seeds the editor at create time. Empty when streaming — the sync effect pushes the
176+ // streamed body in via setContent (this ref is never written again).
167177 const initialBodyRef = useRef ( streamingAtMountRef . current ? '' : splitFrontmatter ( content ) . body )
168178 // The frontmatter re-attached on every change. Empty until the content settles (the editor never
169179 // displays frontmatter, so a streamed doc simply shows its body).
@@ -280,10 +290,14 @@ export function LoadedRichMarkdownEditor({
280290 return
281291 }
282292 if ( settledRef . current === null ) {
283- const { frontmatter, body } = splitFrontmatter ( content )
284- settledRef . current = { frontmatter, verdict : isRoundTripSafe ( content ) }
285- lastSyncedBodyRef . current = body
286- editor . commands . setContent ( body , { contentType : 'markdown' , emitUpdate : false } )
293+ settledRef . current = lockSettled ( content )
294+ // Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't,
295+ // and an extra setContent would needlessly rebuild the doc and drop selection/scroll.
296+ const body = splitFrontmatter ( content ) . body
297+ if ( body !== lastSyncedBodyRef . current ) {
298+ lastSyncedBodyRef . current = body
299+ editor . commands . setContent ( body , { contentType : 'markdown' , emitUpdate : false } )
300+ }
287301 editor . setEditable ( canEdit && settledRef . current . verdict )
288302 if ( autoFocus ) editor . commands . focus ( 'end' )
289303 return
0 commit comments