Skip to content

fix(rich-md-editor): stop the editor flashing during an agent rewrite#5160

Merged
waleedlatif1 merged 2 commits into
stagingfrom
fix/rich-md-stream-flicker
Jun 21, 2026
Merged

fix(rich-md-editor): stop the editor flashing during an agent rewrite#5160
waleedlatif1 merged 2 commits into
stagingfrom
fix/rich-md-stream-flicker

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • When an agent rewrites/edits an existing markdown file (e.g. removing text the user appended), the streamed chunks replaced the doc with each partial result, collapsing the content to a few characters and re-growing on every chunk — visible flashing.
  • Fix: the stream-sync tick now reveals a chunk only when it extends what's already shown. A divergent chunk (a rewrite/edit) holds the current content in place; the final result is applied once on settle. Fresh writes and appends still reveal live.

Empirical validation (e2e harness)

  • Rewrite/remove: content length stays at the settled value across mid-stream chunks (no collapse), then settles to the corrected result.
  • Fresh write: reveals live, growing monotonically.
  • Append-to-existing: holds at the existing length until the stream passes it, then reveals the new tail — never collapses.
  • New regression test added (an agent rewrite does not collapse the editor mid-stream). Full e2e harness (568) + file-viewer vitest (142) + typecheck all green.

Type of Change

  • Bug fix

Testing

Validated empirically via the rich-markdown-editor e2e harness (streaming lifecycle), plus the full suite.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Only reveal streamed chunks that extend what's already shown. A divergent chunk
(an agent rewrite/edit, e.g. removing appended text) would collapse the document
to the partial result and flash on every chunk; now the current content is held
in place and the final result is applied once on settle. Fresh writes still
reveal live.
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 20, 2026 11:50pm

Request Review

@cursor

cursor Bot commented Jun 20, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Targeted UI streaming behavior in the file viewer editor; settle path still applies final content and prefix-based gating is covered by e2e tests.

Overview
Fixes visible flashing when an agent rewrites an existing markdown file: mid-stream chunks no longer replace the doc with partial text that is shorter than what the user already sees.

LoadedRichMarkdownEditor now tracks the body currently shown in lastSyncedBodyRef (seeded on settled mount, updated on local onUpdate and on each applied stream sync). The streaming RAF tick only calls setContent when the pending body extends that shown body (pending.startsWith(shownBody)); divergent chunks are ignored until settle, which still applies the final content. Fresh writes and appends keep live incremental updates.

Also avoids redundant setEditable(false) when the editor is already read-only during streaming.

Reviewed by Cursor Bugbot for commit 5b42a7b. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1149fd8. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents the flash/collapse effect when an agent rewrites a markdown file mid-stream by adding a "prefix guard" to the streaming tick: a chunk is only rendered live if it extends (is a superset prefix of) the content currently shown in the editor. Rewrites that diverge from the current content are held in place, and the final result is applied once on settle.

  • Core guard: lastSyncedBodyRef is seeded at non-streaming mount with splitFrontmatter(content).body (addressing the gap noted in the prior review), updated on user edits via onUpdate, and checked in the streaming tick via pending.startsWith(shownBody).
  • Settle path unchanged: on stream settle, body !== lastSyncedBodyRef.current still triggers a setContent to apply the final rewritten result, so rewrites are never permanently suppressed.
  • Minor cleanup: setEditable(false) in the tick is guarded with editor.isEditable to skip redundant state transitions.

Confidence Score: 5/5

Safe to merge — the change is contained to the streaming tick guard in one component, the settle path still applies the final rewrite unconditionally, and all three streaming modes are handled correctly.

The prefix guard correctly handles all three streaming modes. Fresh writes pass immediately (shownBody is null). Appends pass once the chunk extends the shown content. Rewrites are held without visual collapse and the final result is applied on settle because the settle path checks body !== lastSyncedBodyRef.current unconditionally. The seeding of lastSyncedBodyRef at non-streaming mount (the gap noted in the prior review) is now correctly addressed. The onUpdate path keeps lastSyncedBodyRef accurate for user edits before an agent rewrite, preventing false prefix matches.

No files require special attention — all changes are in a single well-scoped streaming lifecycle function.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx Streaming tick gains a prefix guard to suppress rewrite chunks; lastSyncedBodyRef is moved before useEditor and seeded from mount content; onUpdate now keeps lastSyncedBodyRef current for user edits. Settle path correctly applies the final rewritten result.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as content prop
    participant E as useEffect
    participant T as tick (rAF)
    participant R as lastSyncedBodyRef
    participant Ed as TipTap Editor

    Note over C,Ed: Non-streaming mount
    C->>R: seed with splitFrontmatter(content).body
    C->>Ed: initialContent via parseMarkdownToDoc

    Note over C,Ed: Agent REWRITE stream
    C->>E: "isStreaming=true, content=G"
    E->>T: schedule rAF tick
    T->>R: "read shownBody=Hello world"
    T-->>Ed: "G.startsWith(Hello world)=false HOLD cancel RAF"

    C->>E: "isStreaming=true, content=Goodbye"
    E->>T: schedule new rAF tick
    T->>R: "read shownBody=Hello world"
    T-->>Ed: "Goodbye.startsWith(Hello world)=false HOLD cancel RAF"

    C->>E: "isStreaming=false settle"
    E->>R: update to Goodbye world
    E->>Ed: setContent final result applied once

    Note over C,Ed: Agent APPEND stream
    C->>E: "isStreaming=true content=Hello world!"
    E->>T: schedule rAF tick
    T->>R: "read shownBody=Hello world"
    T->>Ed: "startsWith check=true APPLY live"
    T->>R: "update lastSyncedBodyRef=Hello world!"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as content prop
    participant E as useEffect
    participant T as tick (rAF)
    participant R as lastSyncedBodyRef
    participant Ed as TipTap Editor

    Note over C,Ed: Non-streaming mount
    C->>R: seed with splitFrontmatter(content).body
    C->>Ed: initialContent via parseMarkdownToDoc

    Note over C,Ed: Agent REWRITE stream
    C->>E: "isStreaming=true, content=G"
    E->>T: schedule rAF tick
    T->>R: "read shownBody=Hello world"
    T-->>Ed: "G.startsWith(Hello world)=false HOLD cancel RAF"

    C->>E: "isStreaming=true, content=Goodbye"
    E->>T: schedule new rAF tick
    T->>R: "read shownBody=Hello world"
    T-->>Ed: "Goodbye.startsWith(Hello world)=false HOLD cancel RAF"

    C->>E: "isStreaming=false settle"
    E->>R: update to Goodbye world
    E->>Ed: setContent final result applied once

    Note over C,Ed: Agent APPEND stream
    C->>E: "isStreaming=true content=Hello world!"
    E->>T: schedule rAF tick
    T->>R: "read shownBody=Hello world"
    T->>Ed: "startsWith check=true APPLY live"
    T->>R: "update lastSyncedBodyRef=Hello world!"
Loading

Reviews (3): Last reviewed commit: "fix(rich-markdown-editor): seed shown-bo..." | Re-trigger Greptile

… local edits

Seed lastSyncedBodyRef from a settled (non-streaming) mount and update it on
local edits via onUpdate, so the streaming hold engages on the very first agent
rewrite chunk (no collapse/flash) and the settle still applies the rewrite that
removes a local edit.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile good catch on the P2 — confirmed real and fixed in 5b42a7ba9b.

The gap: lastSyncedBodyRef started null and was only seeded via the stream→settle path, so a file opened settled (the common case — opened from disk, never streamed) left it null. The streaming tick then treated null as a fresh write (extendsShown true) and revealed the first rewrite chunk → the editor collapsed to a partial before re-growing (the flash).

Fix (two complementary parts):

  • Seed lastSyncedBodyRef from the body at a settled mount, so the hold engages on the very first chunk without relying on onUpdate having fired.
  • Update it in onUpdate on local edits, so after a user edits and the agent rewrites, the settle still re-applies the agent's result (e.g. removing a locally-typed "more more more").

Empirically verified in the e2e harness with a settled mount: removing both the seed and the onUpdate update collapses the editor 63 → 4 → 10 → 28 chars across rewrite chunks (the flash); with the fix it holds at 63 and settles to the rewrite. Two regression tests added: settled-mount rewrite (no collapse) and settled-mount + local edit (rewrite still applied).

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

The fix is well-targeted and the reasoning is solid. A few observations on the implementation:

The seed is correct. lastSyncedBodyRef now initializes to splitFrontmatter(content).body when !streamingAtMountRef.current, which means a file opened settled has a non-null baseline. The streaming tick's extendsShown = shownBody === null || pending.startsWith(shownBody) will evaluate pending.startsWith(body) for the first rewrite chunk, correctly returning false and holding content. Good.

The onUpdate path is also correct. Updating lastSyncedBodyRef on local edits means the hold compares against the user's actual current content, not the stale settled body. Without this, after a user types "more more more", the settled body is "original" but the editor has "original more more more" — the agent's rewrite chunk ("rewrite") would fail startsWith("original more more more") correctly, but the settle's body !== lastSyncedBodyRef.current check would compare against "original" instead of "original more more more" and potentially skip re-applying. With the onUpdate tracking, lastSyncedBodyRef reflects the actual editor content, so the settle correctly detects the divergence and applies the rewrite. That's the exact scenario you described.

One minor observation: onUpdate calls editor.getMarkdown() + postProcessSerializedMarkdown on every keystroke to update lastSyncedBodyRef, but that same serialization is already needed for onChangeRef.current(...) — one serialization, two consumers. No redundancy there.

The fix is clean — two complementary invariants, minimal surface area, no regressions to the streaming path. LGTM.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5b42a7b. Configure here.

@waleedlatif1 waleedlatif1 merged commit cb17207 into staging Jun 21, 2026
16 checks passed
@waleedlatif1 waleedlatif1 deleted the fix/rich-md-stream-flicker branch June 21, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant