Skip to content

Commit 6773fe9

Browse files
committed
improve performance
1 parent eb5c1d0 commit 6773fe9

7 files changed

Lines changed: 319 additions & 45 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ 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+
*/
40+
const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
41+
const STREAM_REPARSE_THROTTLE_MS = 120
42+
3343
interface RichMarkdownEditorProps {
3444
file: WorkspaceFileRecord
3545
workspaceId: string
@@ -184,9 +194,6 @@ export function LoadedRichMarkdownEditor({
184194
const [initialContent] = useState<JSONContent | string>(() =>
185195
streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body)
186196
)
187-
// Frontmatter held aside and re-attached on every change (the editor never shows it); re-derived per
188-
// stream→settle in the settle effect, so a repeat stream uses the new doc's frontmatter, not a stale one.
189-
const frontmatterRef = useRef(settledRef.current?.frontmatter ?? '')
190197
const onChangeRef = useRef(onChange)
191198
onChangeRef.current = onChange
192199
const onSaveShortcutRef = useRef(onSaveShortcut)
@@ -300,22 +307,18 @@ export function LoadedRichMarkdownEditor({
300307
},
301308
onUpdate: ({ editor }) => {
302309
const md = postProcessSerializedMarkdown(editor.getMarkdown())
303-
onChangeRef.current(applyFrontmatter(frontmatterRef.current, md))
310+
onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md))
304311
},
305312
})
306313
editorInstanceRef.current = editor
307314

308-
// Stream content in read-only until it settles, then lock the verdict + frontmatter and hand off; after
309-
// that only `canEdit` touches the editor (it owns the content, so no sync can clobber a user edit).
310315
const lastSyncedBodyRef = useRef<string | null>(null)
311-
// Tracks whether the previous run was streaming so the settle branch re-locks on every stream→settle:
312-
// one instance can receive several agent edits in a chat (kept mounted by `previewContextKey`), so the
313-
// verdict/frontmatter must follow the latest stream, not the first settled snapshot.
316+
314317
const wasStreamingRef = useRef(streamingAtMountRef.current)
315-
// Coalesce streamed chunks to one re-parse per animation frame — a fast agent emits many per frame and
316-
// each would re-parse the whole accumulating body. Read-only while streaming, so only the latest renders.
318+
317319
const pendingStreamBodyRef = useRef<string | null>(null)
318320
const streamRafRef = useRef<number | null>(null)
321+
const lastStreamParseAtRef = useRef(0)
319322
useEffect(() => {
320323
if (!editor) return
321324
if (isStreaming) {
@@ -324,11 +327,26 @@ export function LoadedRichMarkdownEditor({
324327
if (body === lastSyncedBodyRef.current) return
325328
pendingStreamBodyRef.current = body
326329
if (streamRafRef.current !== null) return
327-
streamRafRef.current = requestAnimationFrame(() => {
328-
streamRafRef.current = null
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.
334+
const tick = () => {
329335
const pending = pendingStreamBodyRef.current
330-
if (pending === null || pending === lastSyncedBodyRef.current) return
336+
if (pending === null || pending === lastSyncedBodyRef.current) {
337+
streamRafRef.current = null
338+
return
339+
}
340+
if (
341+
pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD &&
342+
performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS
343+
) {
344+
streamRafRef.current = requestAnimationFrame(tick)
345+
return
346+
}
347+
streamRafRef.current = null
331348
lastSyncedBodyRef.current = pending
349+
lastStreamParseAtRef.current = performance.now()
332350
const el = containerRef.current
333351
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
334352
editor.setEditable(false)
@@ -337,7 +355,8 @@ export function LoadedRichMarkdownEditor({
337355
emitUpdate: false,
338356
})
339357
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
340-
})
358+
}
359+
streamRafRef.current = requestAnimationFrame(tick)
341360
return
342361
}
343362
// Drop a frame scheduled just before settle so it can't land afterward and clobber the final content.
@@ -352,7 +371,6 @@ export function LoadedRichMarkdownEditor({
352371
if (isInitialSettle || wasStreamingRef.current) {
353372
wasStreamingRef.current = false
354373
settledRef.current = lockSettled(content)
355-
frontmatterRef.current = settledRef.current.frontmatter
356374
// Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't,
357375
// and an extra setContent would needlessly rebuild the doc and drop selection/scroll.
358376
const body = splitFrontmatter(content).body

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,24 @@ import {
1010
} from './text-editor-state'
1111

1212
function ready(content: string, savedContent = content): TextEditorContentState {
13-
return { phase: 'ready', content, savedContent, lastStreamedContent: null }
13+
return { phase: 'ready', content, savedContent, lastStreamedContent: null, hasBaseline: true }
1414
}
1515

1616
function streaming(
1717
content: string,
1818
lastStreamedContent: string,
19-
savedContent = ''
19+
savedContent = '',
20+
hasBaseline = true
2021
): TextEditorContentState {
21-
return { phase: 'streaming', content, savedContent, lastStreamedContent }
22+
return { phase: 'streaming', content, savedContent, lastStreamedContent, hasBaseline }
2223
}
2324

24-
function reconciling(content: string, savedContent = ''): TextEditorContentState {
25-
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null }
25+
function reconciling(
26+
content: string,
27+
savedContent = '',
28+
hasBaseline = true
29+
): TextEditorContentState {
30+
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null, hasBaseline }
2631
}
2732

2833
describe("reducer 'edit' action", () => {
@@ -144,6 +149,7 @@ describe('syncTextEditorContentState — static fetch updates', () => {
144149
content: 'user edit',
145150
savedContent: 'v1',
146151
lastStreamedContent: null,
152+
hasBaseline: true,
147153
}
148154
const next = syncTextEditorContentState(state, {
149155
canReconcileToFetchedContent: true,
@@ -241,6 +247,7 @@ describe('syncTextEditorContentState — reconciling', () => {
241247
content: 'streamed',
242248
savedContent: 'v1',
243249
lastStreamedContent: null,
250+
hasBaseline: true,
244251
}
245252
const next = syncTextEditorContentState(state, {
246253
canReconcileToFetchedContent: true,
@@ -431,3 +438,69 @@ describe('syncTextEditorContentState — mothership streamed-file lifecycle (rep
431438
expect(state.content).toBe(state.savedContent)
432439
})
433440
})
441+
442+
/**
443+
* When the user opens an existing, non-empty file's tab while the agent is already mid-stream on it,
444+
* streaming begins from `uninitialized` before the content fetch resolves — so `savedContent` is the
445+
* placeholder `''`. The first fetched value to arrive is the file's PRE-EDIT content, not the agent's
446+
* write; it must be adopted as the baseline, never finalized to (which would flash stale content and,
447+
* if the agent had stopped, let the user edit over the agent's write).
448+
*/
449+
describe('syncTextEditorContentState — stream begins before fetch on an existing file', () => {
450+
it('adopts the first fetched content as the baseline instead of finalizing to it mid-stream', () => {
451+
const preEdit = '# Original\n\nold content'
452+
const agentWrite = '# Original\n\nold content, plus a new section.'
453+
454+
// 1. Editor mounts mid-stream: chunk arrives before the fetch resolves.
455+
let state = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
456+
canReconcileToFetchedContent: true,
457+
fetchedContent: undefined,
458+
streamingContent: '# Original\n\nold',
459+
})
460+
expect(state.phase).toBe('streaming')
461+
expect(state.savedContent).toBe('')
462+
expect(state.hasBaseline).toBe(false)
463+
464+
// 2. The fetch resolves to the file's pre-edit content WHILE streaming. Adopt it as the baseline;
465+
// do NOT finalize (the agent hasn't persisted its write yet).
466+
state = syncTextEditorContentState(state, {
467+
canReconcileToFetchedContent: true,
468+
fetchedContent: preEdit,
469+
streamingContent: '# Original\n\nold content, plus',
470+
})
471+
expect(state.phase).toBe('streaming')
472+
expect(state.content).toBe('# Original\n\nold content, plus')
473+
expect(state.savedContent).toBe(preEdit)
474+
expect(state.hasBaseline).toBe(true)
475+
476+
// 3. Stream ends; the refetch is still the pre-edit content → hold in reconciling, never finalize
477+
// to stale (savedContent === fetched, so it has not "advanced").
478+
state = syncTextEditorContentState(state, {
479+
canReconcileToFetchedContent: true,
480+
fetchedContent: preEdit,
481+
streamingContent: undefined,
482+
})
483+
expect(state.phase).toBe('reconciling')
484+
485+
// 4. The agent's write lands (advanced past the adopted baseline) → finalize to it.
486+
state = syncTextEditorContentState(state, {
487+
canReconcileToFetchedContent: true,
488+
fetchedContent: agentWrite,
489+
streamingContent: undefined,
490+
})
491+
expect(state.phase).toBe('ready')
492+
expect(state.content).toBe(agentWrite)
493+
expect(state.savedContent).toBe(agentWrite)
494+
})
495+
496+
it('still finalizes mid-stream once a real baseline is established (no regression)', () => {
497+
// With hasBaseline=true, an advancing fetch finalizes immediately — the established-baseline path.
498+
const next = syncTextEditorContentState(streaming('v1 chunk', 'v1 chunk', 'v1'), {
499+
canReconcileToFetchedContent: true,
500+
fetchedContent: 'v2',
501+
streamingContent: 'chunk',
502+
})
503+
expect(next.phase).toBe('ready')
504+
expect(next.content).toBe('v2')
505+
})
506+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ export interface TextEditorContentState {
55
content: string
66
savedContent: string
77
lastStreamedContent: string | null
8+
/**
9+
* Whether `savedContent` is the file's real baseline (not the initial placeholder). False only
10+
* before the first fetched content has been observed — e.g. a stream that began before the initial
11+
* fetch resolved. While false, a fetched value is treated as the baseline to adopt, not as the
12+
* agent's write advancing past the baseline (which would finalize the editor to stale content).
13+
*/
14+
hasBaseline: boolean
815
}
916

1017
export interface SyncTextEditorContentStateOptions {
@@ -23,6 +30,7 @@ export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = {
2330
content: '',
2431
savedContent: '',
2532
lastStreamedContent: null,
33+
hasBaseline: false,
2634
}
2735

2836
function finalizeTextEditorContentState(
@@ -33,7 +41,8 @@ function finalizeTextEditorContentState(
3341
state.phase === 'ready' &&
3442
state.content === nextContent &&
3543
state.savedContent === nextContent &&
36-
state.lastStreamedContent === null
44+
state.lastStreamedContent === null &&
45+
state.hasBaseline
3746
) {
3847
return state
3948
}
@@ -43,17 +52,30 @@ function finalizeTextEditorContentState(
4352
content: nextContent,
4453
savedContent: nextContent,
4554
lastStreamedContent: null,
55+
hasBaseline: true,
4656
}
4757
}
4858

4959
function moveTextEditorContentStateToStreaming(
5060
state: TextEditorContentState,
51-
nextContent: string
61+
nextContent: string,
62+
fetchedBaseline?: string
5263
): TextEditorContentState {
64+
// A stream that begins before the initial fetch resolves leaves `savedContent` at its placeholder.
65+
// The first fetched value to arrive during the stream IS the file's pre-edit baseline (the agent
66+
// hasn't persisted its write yet), so adopt it. Without this, a later refetch of that same pre-edit
67+
// content would read as an "advance" past the placeholder and finalize the editor to stale content
68+
// mid-stream. Empty-file creates are unaffected: their baseline genuinely is ''.
69+
const adoptBaseline = !state.hasBaseline && fetchedBaseline !== undefined
70+
const savedContent = adoptBaseline ? fetchedBaseline : state.savedContent
71+
const hasBaseline = state.hasBaseline || adoptBaseline
72+
5373
if (
5474
state.phase === 'streaming' &&
5575
state.content === nextContent &&
56-
state.lastStreamedContent === nextContent
76+
state.lastStreamedContent === nextContent &&
77+
state.savedContent === savedContent &&
78+
state.hasBaseline === hasBaseline
5779
) {
5880
return state
5981
}
@@ -63,6 +85,8 @@ function moveTextEditorContentStateToStreaming(
6385
phase: 'streaming',
6486
content: nextContent,
6587
lastStreamedContent: nextContent,
88+
savedContent,
89+
hasBaseline,
6690
}
6791
}
6892

@@ -92,7 +116,12 @@ export function syncTextEditorContentState(
92116
fetchedContent !== undefined &&
93117
state.lastStreamedContent !== null &&
94118
fetchedContent === state.lastStreamedContent
95-
const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent
119+
// Only an ESTABLISHED baseline makes "fetched differs from savedContent" mean "the agent's write
120+
// advanced". Before the baseline is established (stream started before the fetch resolved),
121+
// savedContent is a placeholder, so the file's own pre-edit content would falsely read as an
122+
// advance and finalize to stale content; instead it is adopted as the baseline in moveToStreaming.
123+
const hasFetchedAdvanced =
124+
fetchedContent !== undefined && state.hasBaseline && fetchedContent !== state.savedContent
96125

97126
if (
98127
(state.phase === 'streaming' || state.phase === 'reconciling') &&
@@ -110,7 +139,7 @@ export function syncTextEditorContentState(
110139
return finalizeTextEditorContentState(state, fetchedContent)
111140
}
112141

113-
return moveTextEditorContentStateToStreaming(state, nextContent)
142+
return moveTextEditorContentStateToStreaming(state, nextContent, fetchedContent)
114143
}
115144

116145
if (state.phase === 'streaming' || state.phase === 'reconciling') {
@@ -182,6 +211,7 @@ export function textEditorContentReducer(
182211
phase: 'ready',
183212
savedContent: action.content,
184213
lastStreamedContent: null,
214+
hasBaseline: true,
185215
}
186216
default:
187217
return state

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,6 @@ export function useEditableFileContent({
129129
const updateContentRef = useRef(updateContent)
130130
updateContentRef.current = updateContent
131131

132-
// Pace only the streamed text (never user edits) so the preview reveals at the same word-by-word
133-
// cadence as chat. Off-stream it reverts to undefined so the engine reconciles to the agent's
134-
// persisted write; snapOnNonAppend shows in-place rewrites/patches in full instead of re-revealing.
135-
const pacedStreamingContent = useSmoothText(
136-
streamingContent ?? '',
137-
streamingContent !== undefined,
138-
{
139-
snapOnNonAppend: true,
140-
}
141-
)
142-
const effectiveStreamingContent =
143-
streamingContent !== undefined ? pacedStreamingContent : undefined
144-
145132
const {
146133
content,
147134
savedContent,
@@ -152,11 +139,20 @@ export function useEditableFileContent({
152139
} = useFileContentState({
153140
canReconcileToFetchedContent: file.key.length > 0,
154141
fetchedContent,
155-
streamingContent: effectiveStreamingContent,
142+
streamingContent,
156143
})
157144

158145
const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing)
159146

147+
// Pace the streamed reveal for DISPLAY only. The reducer above keeps the true content so
148+
// reconciliation, dirty tracking, and saves are never thrown off by the paced prefix. Pacing is
149+
// gated on the stream phase (not the agent-edit lock) and fed '' off-stream, so a user's own typing
150+
// is never throttled; snapOnNonAppend shows in-place rewrites/patches in full, not re-revealed.
151+
const pacedReveal = useSmoothText(isStreamPhaseLocked ? content : '', isStreamPhaseLocked, {
152+
snapOnNonAppend: true,
153+
})
154+
const displayContent = isStreamPhaseLocked ? pacedReveal : content
155+
160156
const contentRef = useRef(content)
161157
contentRef.current = content
162158

@@ -192,11 +188,16 @@ export function useEditableFileContent({
192188
}, [saveImmediately, saveRef])
193189

194190
return {
195-
content,
191+
content: displayContent,
196192
setDraftContent,
197193
isInitialized,
198194
isStreamInteractionLocked,
199-
isContentLoading: streamingContent === undefined && isLoading,
195+
// `!isInitialized` mirrors `hasContentError`: once any content (fetched OR streamed) has
196+
// initialized the editor, never fall back to the loading frame. A stream that finishes before the
197+
// initial file fetch resolves flips `streamingContent` to undefined while `isLoading` is still
198+
// true — without this guard that would unmount the settled editor (losing the read-only→editable
199+
// hand-off, scroll, and parsed doc) until the fetch lands.
200+
isContentLoading: streamingContent === undefined && isLoading && !isInitialized,
200201
hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized,
201202
saveStatus,
202203
saveImmediately,

0 commit comments

Comments
 (0)