@@ -7,7 +7,6 @@ import { cn } from '@/lib/core/utils/cn'
77import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files'
99import type { SaveStatus } from '@/hooks/use-autosave'
10- import { PreviewPanel } from '../preview-panel'
1110import { PreviewLoadingFrame } from '../preview-shared'
1211import type { StreamingMode } from '../text-editor-state'
1312import { useEditableFileContent } from '../use-editable-file-content'
@@ -96,29 +95,17 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
9695 )
9796 }
9897
99- if ( isStreamInteractionLocked ) {
100- return (
101- < PreviewPanel
102- key = { previewContextKey ? `${ file . id } :${ previewContextKey } ` : file . id }
103- content = { content }
104- mimeType = { file . type }
105- filename = { file . name }
106- workspaceId = { workspaceId }
107- fileKey = { file . key }
108- isStreaming
109- disableAutoScroll = { disableStreamingAutoScroll }
110- />
111- )
112- }
113-
11498 return (
11599 < LoadedRichMarkdownEditor
116- key = { file . id }
100+ // Remount on a new streaming context so the stream/settle state is re-established fresh.
101+ key = { previewContextKey ? `${ file . id } :${ previewContextKey } ` : file . id }
117102 file = { file }
118103 workspaceId = { workspaceId }
119- initialContent = { content }
104+ content = { content }
105+ streaming = { isStreamInteractionLocked }
120106 canEdit = { canEdit }
121107 autoFocus = { autoFocus }
108+ disableStreamingAutoScroll = { disableStreamingAutoScroll }
122109 onChange = { setDraftContent }
123110 onSaveShortcut = { saveImmediately }
124111 />
@@ -128,54 +115,66 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
128115interface LoadedRichMarkdownEditorProps {
129116 file : WorkspaceFileRecord
130117 workspaceId : string
131- initialContent : string
118+ /** The live content from the engine — grows as the agent streams, then settles to the saved doc. */
119+ content : string
120+ /** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */
121+ streaming : boolean
132122 canEdit : boolean
133123 autoFocus ?: boolean
124+ disableStreamingAutoScroll ?: boolean
134125 onChange : ( markdown : string ) => void
135126 onSaveShortcut : ( ) => Promise < void >
136127}
137128
138129/**
139- * The mounted TipTap editor. Receives the file's loaded markdown as {@link initialContent} and hands
140- * it to {@link useEditor} as the initial document (parsed at create time by the markdown extension),
141- * so there is no imperative content sync. Frontmatter is held aside and re-applied on every change,
142- * so the editor only ever round-trips the body.
130+ * The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
131+ * output streams in ({@link streaming}) it renders that content read-only and re-syncs each chunk;
132+ * when the stream settles it locks the round-trip verdict + frontmatter on the final content and
133+ * hands control to the user. A file opened outside a stream skips straight to that editable state via
134+ * the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every
135+ * change, so the editor only ever round-trips the body.
143136 */
144- function LoadedRichMarkdownEditor ( {
137+ export function LoadedRichMarkdownEditor ( {
145138 file,
146139 workspaceId,
147- initialContent,
140+ content,
141+ streaming,
148142 canEdit,
149143 autoFocus,
144+ disableStreamingAutoScroll,
150145 onChange,
151146 onSaveShortcut,
152147} : LoadedRichMarkdownEditorProps ) {
153- // Whether the opened content round-trips losslessly through the editor — computed once, on the
154- // exact content the editor opens with (keyed by file id, so it remounts per file), and locked for
155- // the editor's lifetime. A round-trip-unsafe document (raw HTML, footnotes, >128KB, …) opens
156- // read-only so an edit can't corrupt it; a safe one stays editable. It is never re-derived: a
157- // dirty document is round-trip-safe by construction (the editor only emits safe markdown), so
158- // flipping editability off mid-edit would only strand unsaved edits (autosave, ⌘S, the toolbar
159- // Save, and the unmount flush all gate on it).
160- const roundTripSafeRef = useRef < boolean | null > ( null )
161- if ( roundTripSafeRef . current === null ) {
162- roundTripSafeRef . current = isRoundTripSafe ( initialContent )
163- }
164- const isEditable = canEdit && roundTripSafeRef . current
148+ // Whether this editor mounted mid-stream. If so it starts empty + read-only and syncs the streamed
149+ // content until the stream settles; otherwise it uses the plain create-time initial-content model.
150+ const streamingAtMountRef = useRef ( streaming )
165151
166- // Split frontmatter off once, on the opened content (stable for the editor's lifetime, like the
167- // verdict above): the body seeds the editor's initial document, and the frontmatter is re-attached
168- // on every change so the editor only ever round-trips the body.
169- const splitRef = useRef < { frontmatter : string ; body : string } | null > ( null )
170- if ( splitRef . current === null ) {
171- splitRef . current = splitFrontmatter ( initialContent )
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 )
158+ if ( ! streamingAtMountRef . current && settledRef . current === null ) {
159+ settledRef . current = {
160+ frontmatter : splitFrontmatter ( content ) . frontmatter ,
161+ verdict : isRoundTripSafe ( content ) ,
162+ }
172163 }
173- const { frontmatter, body } = splitRef . current
164+ const isEditable = canEdit && ! streaming && ( settledRef . current ?. verdict ?? false )
165+
166+ // The body that seeds the editor at create time — empty when streaming (filled by the sync effect).
167+ const initialBodyRef = useRef ( streamingAtMountRef . current ? '' : splitFrontmatter ( content ) . body )
168+ // The frontmatter re-attached on every change. Empty until the content settles (the editor never
169+ // displays frontmatter, so a streamed doc simply shows its body).
170+ const frontmatterRef = useRef ( '' )
171+ frontmatterRef . current = settledRef . current ?. frontmatter ?? ''
174172 const onChangeRef = useRef ( onChange )
175173 onChangeRef . current = onChange
176174 const onSaveShortcutRef = useRef ( onSaveShortcut )
177175 onSaveShortcutRef . current = onSaveShortcut
178176
177+ const containerRef = useRef < HTMLDivElement > ( null )
179178 const uploadFile = useUploadWorkspaceFile ( )
180179 const editorInstanceRef = useRef < Editor | null > ( null )
181180
@@ -215,10 +214,10 @@ function LoadedRichMarkdownEditor({
215214 const editor = useEditor ( {
216215 extensions : EXTENSIONS ,
217216 editable : isEditable ,
218- autofocus : autoFocus ? 'end' : false ,
217+ autofocus : streamingAtMountRef . current ? false : autoFocus ? 'end' : false ,
219218 immediatelyRender : false ,
220219 shouldRerenderOnTransaction : false ,
221- content : body ,
220+ content : initialBodyRef . current ,
222221 contentType : 'markdown' ,
223222 editorProps : {
224223 attributes : { class : 'rich-markdown-prose' } ,
@@ -258,17 +257,45 @@ function LoadedRichMarkdownEditor({
258257 } ,
259258 onUpdate : ( { editor } ) => {
260259 const md = postProcessSerializedMarkdown ( editor . getMarkdown ( ) )
261- onChangeRef . current ( applyFrontmatter ( frontmatter , md ) )
260+ onChangeRef . current ( applyFrontmatter ( frontmatterRef . current , md ) )
262261 } ,
263262 } )
264263 editorInstanceRef . current = editor
265264
265+ // Stream content into the editor (read-only) until it settles, then lock the verdict + frontmatter
266+ // and hand control to the user. After the hand-off, only `canEdit` changes touch the editor — the
267+ // editor owns the content, so there is no sync that could clobber a user edit.
268+ const lastSyncedBodyRef = useRef < string | null > ( null )
266269 useEffect ( ( ) => {
267- editor ?. setEditable ( isEditable )
268- } , [ editor , isEditable ] )
270+ if ( ! editor ) return
271+ if ( streaming ) {
272+ const body = splitFrontmatter ( content ) . body
273+ if ( body === lastSyncedBodyRef . current ) return
274+ lastSyncedBodyRef . current = body
275+ const el = containerRef . current
276+ const pinnedToBottom = el ? el . scrollHeight - el . scrollTop - el . clientHeight < 80 : false
277+ editor . setEditable ( false )
278+ editor . commands . setContent ( body , { contentType : 'markdown' , emitUpdate : false } )
279+ if ( ! disableStreamingAutoScroll && el && pinnedToBottom ) el . scrollTop = el . scrollHeight
280+ return
281+ }
282+ 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 } )
287+ editor . setEditable ( canEdit && settledRef . current . verdict )
288+ if ( autoFocus ) editor . commands . focus ( 'end' )
289+ return
290+ }
291+ editor . setEditable ( canEdit && settledRef . current . verdict )
292+ } , [ editor , content , streaming , canEdit , autoFocus , disableStreamingAutoScroll ] )
269293
270294 return (
271- < div className = { cn ( 'flex flex-1 flex-col overflow-y-auto' , isEditable && 'cursor-text' ) } >
295+ < div
296+ ref = { containerRef }
297+ className = { cn ( 'flex flex-1 flex-col overflow-y-auto' , isEditable && 'cursor-text' ) }
298+ >
272299 { editor && < EditorBubbleMenu editor = { editor } /> }
273300 < EditorContent
274301 editor = { editor }
0 commit comments