@@ -30,13 +30,7 @@ const EXTENSIONS = createMarkdownEditorExtensions({
3030 placeholder : "Write something, or press '/' for commands…" ,
3131} )
3232
33- /**
34- * Each streamed re-sync re-parses the whole accumulating body and rebuilds the ProseMirror doc —
35- * ~O(n) per frame (~22ms at 60KB; see {@link parseMarkdownToDoc}). Below this size we re-sync every
36- * frame for a smooth reveal; at or above it we throttle to {@link STREAM_REPARSE_THROTTLE_MS} so a
37- * large file doesn't saturate the main thread with full re-parses the reader can't follow anyway. The
38- * settle path always re-seeds the exact final body, so throttling only affects mid-stream cadence.
39- */
33+ // Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread.
4034const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
4135const STREAM_REPARSE_THROTTLE_MS = 120
4236
@@ -55,18 +49,7 @@ interface RichMarkdownEditorProps {
5549 showBubbleMenu ?: boolean
5650}
5751
58- /**
59- * Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files — a single editing surface
60- * (markdown transformed inline as you type), no raw/preview split and no separate streaming preview.
61- * Owns the file lifecycle through a single {@link useEditableFileContent} engine, and the TipTap
62- * editor is the ONLY thing the user ever sees: while agent output streams in it renders that content
63- * read-only (synced per chunk), then the same editor instance becomes editable once the stream
64- * settles — so the stream→edit transition has no renderer swap or flash.
65- *
66- * The editor is keyed by file id (+ streaming context). A file opened outside a stream uses the plain
67- * create-time initial-content model (no sync). See {@link LoadedRichMarkdownEditor} for the
68- * read-only-stream → editable hand-off.
69- */
52+ /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
7053export const RichMarkdownEditor = memo ( function RichMarkdownEditor ( {
7154 file,
7255 workspaceId,
@@ -147,25 +130,12 @@ interface SettledContent {
147130 verdict : boolean
148131}
149132
150- /**
151- * Lock the round-trip verdict + frontmatter on the content the editor "opens" with — once, at mount
152- * for a settled file or at the moment a stream settles. A round-trip-unsafe document (raw HTML,
153- * footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a safe one stays editable. Never
154- * re-derived: a dirty document is safe by construction (the editor only emits safe markdown), so
155- * flipping editability off mid-edit would only strand edits.
156- */
133+ /** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >128KB) opens read-only. */
157134function lockSettled ( content : string ) : SettledContent {
158135 return { frontmatter : splitFrontmatter ( content ) . frontmatter , verdict : isRoundTripSafe ( content ) }
159136}
160137
161- /**
162- * The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
163- * output streams in ({@link isStreaming}) it renders that content read-only and re-syncs each chunk;
164- * when the stream settles it locks the round-trip verdict + frontmatter on the final content and
165- * hands control to the user. A file opened outside a stream skips straight to that editable state via
166- * the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every
167- * change, so the editor only ever round-trips the body.
168- */
138+ /** The single TipTap editor: read-only while streaming, editable on settle; frontmatter is held aside and re-applied. */
169139export function LoadedRichMarkdownEditor ( {
170140 file,
171141 workspaceId,
@@ -181,16 +151,14 @@ export function LoadedRichMarkdownEditor({
181151 // Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle.
182152 const streamingAtMountRef = useRef ( isStreaming )
183153
184- // Verdict + frontmatter locked once via {@link lockSettled} (at mount when settled, else when the
185- // stream settles below); null until then reads as read-only.
154+ // Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only.
186155 const settledRef = useRef < SettledContent | null > ( null )
187156 if ( ! streamingAtMountRef . current && settledRef . current === null ) {
188157 settledRef . current = lockSettled ( content )
189158 }
190159 const isEditable = canEdit && ! isStreaming && ( settledRef . current ?. verdict ?? false )
191160
192- // Seed the editor with the chunked-parsed doc (linear vs the editor's ~O(n²) markdown parse), computed
193- // once via lazy state init — `useRef(parseMarkdownToDoc(...))` would re-parse the whole body every render.
161+ // Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse.
194162 const [ initialContent ] = useState < JSONContent | string > ( ( ) =>
195163 streamingAtMountRef . current ? '' : parseMarkdownToDoc ( splitFrontmatter ( content ) . body )
196164 )
@@ -206,12 +174,7 @@ export function LoadedRichMarkdownEditor({
206174 const uploadFile = useUploadWorkspaceFile ( )
207175 const editorInstanceRef = useRef < Editor | null > ( null )
208176
209- /**
210- * Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor under
211- * the pointer). Sequential so multiple images stack in order; the upload hook surfaces its own
212- * success/error toasts, so a failed upload is skipped without interrupting the rest. Held in a ref
213- * (reassigned each render) so the once-built `editorProps` handlers always reach the latest values.
214- */
177+ // Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest.
215178 const insertImagesRef = useRef < ( images : File [ ] , at : number ) => Promise < void > > ( ( ) =>
216179 Promise . resolve ( )
217180 )
@@ -258,11 +221,9 @@ export function LoadedRichMarkdownEditor({
258221 handleClick : ( view , _pos , event ) => {
259222 const href = ( event . target as HTMLElement | null ) ?. closest ( 'a' ) ?. getAttribute ( 'href' )
260223 if ( ! href ) return false
261- // Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g.
262- // the public share page): a plain click follows the link.
224+ // Editing requires a modifier to follow a link (a plain click places the cursor); read-only follows it directly.
263225 if ( view . editable && ! ( event . metaKey || event . ctrlKey ) ) return false
264- // Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab,
265- // restoring the table-of-contents links that worked via rehype-slug in the old preview.
226+ // Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab.
266227 if ( href . startsWith ( '#' ) ) {
267228 const pos = findHeadingPos ( view . state . doc , href . slice ( 1 ) )
268229 if ( pos < 0 ) return false
@@ -274,8 +235,7 @@ export function LoadedRichMarkdownEditor({
274235 }
275236 const normalized = normalizeLinkHref ( href )
276237 if ( ! normalized ) return false
277- // A same-origin in-app path navigates within the SPA (same tab) — unless the reader
278- // modifier-clicked for a new tab. External URLs always open a new tab.
238+ // A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab.
279239 if (
280240 ! ( event . metaKey || event . ctrlKey ) &&
281241 normalized . startsWith ( '/' ) &&
@@ -327,10 +287,7 @@ export function LoadedRichMarkdownEditor({
327287 if ( body === lastSyncedBodyRef . current ) return
328288 pendingStreamBodyRef . current = body
329289 if ( streamRafRef . current !== null ) return
330- // Self-re-arming tick: it parses the latest pending body, but for a large body that exceeds the
331- // re-parse budget it re-schedules instead (a cheap length+clock check, no parse) until enough
332- // time has passed — so newer chunks keep updating `pendingStreamBodyRef` and only the latest is
333- // ever parsed. Settle cancels any in-flight tick and re-seeds the final body.
290+ // Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due.
334291 const tick = ( ) => {
335292 const pending = pendingStreamBodyRef . current
336293 if ( pending === null || pending === lastSyncedBodyRef . current ) {
@@ -364,15 +321,12 @@ export function LoadedRichMarkdownEditor({
364321 cancelAnimationFrame ( streamRafRef . current )
365322 streamRafRef . current = null
366323 }
367- // Settle: re-lock the verdict + frontmatter on the freshly-settled content — on the first settle and
368- // every later stream→settle, so a repeat agent edit gates on the NEW content, not a stale snapshot.
369- // User edits never reach here (`isStreaming`/`wasStreamingRef` stay false), preserving don't-strand-edits.
324+ // Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first).
370325 const isInitialSettle = settledRef . current === null
371326 if ( isInitialSettle || wasStreamingRef . current ) {
372327 wasStreamingRef . current = false
373328 settledRef . current = lockSettled ( content )
374- // Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't,
375- // and an extra setContent would needlessly rebuild the doc and drop selection/scroll.
329+ // Re-seed only if the settled body differs from the last streamed chunk (avoids a needless doc rebuild + selection loss).
376330 const body = splitFrontmatter ( content ) . body
377331 if ( body !== lastSyncedBodyRef . current ) {
378332 lastSyncedBodyRef . current = body
0 commit comments