Skip to content

Commit 630e7cb

Browse files
authored
feat(copilot): stream file edits into the live collaborative Y.Doc (keep embedded view collaborative) (#6108)
* feat(copilot): stream file edits into the live collaborative Y.Doc Copilot's file edits previously only reached the live doc once, at the final edit_content write, so a collaborative editor watching the file saw nothing until completion (streaming looked broken) and the client-side preview path was suppressed in collab mode. Make copilot a CRDT peer: as it streams append/update/patch content, merge the growing markdown into the file's live Y.Doc via the existing apply-edit path (a minimal updateYFragment diff, concurrent-edit-safe), throttled to ~250ms. version is omitted for these intermediate merges — they advance the live doc for viewers but are not durable checkpoints; the final edit_content write carries the real contentUpdatedAt and reconciles the durable file. Per the relay's persist gating, server-internal merges never schedule a persist, so a copilot-only stream produces zero intermediate file writes. - notify.ts: mergeEditIntoLiveFileDoc version is now optional (streaming omits it). - file-preview-adapter.ts: throttled live-doc merge at the edit_content stream hook. * fix(copilot): order + gate streaming live-doc merges; fast collab first render Harden the streaming merge (adversarial review): - Order + bound: dispatch through a per-file in-flight guard (drop-while-in-flight) so a stale out-of-order snapshot can never land after a newer one and regress the doc, and relay load is capped at one request per file regardless of rate. - No wipe: gate append/patch on the base file content having loaded — a base-less snapshot would diff to a delete-everything wipe of the seeded doc; update streams a full rewrite from scratch and needs no base. - Markdown-only gate: non-markdown files have no collaborative room, so skip the wasted relay round-trip. Fast collab first render (Issue 2): render the already-fetched markdown read-only via generateHTML while the collaborative doc seeds, with the editor mounted-but- hidden in the same layout box for a seamless swap on collabReady. Pure HTML — it never touches the Y.Doc (client seeding duplicates the doc), and generateHTML escapes text (raw-HTML snippets render escaped), so no XSS. * test(copilot): cover streaming file edits into the live collaborative Y.Doc Drives edit_content args_delta stream events through processFilePreviewStreamEvent and asserts the live-doc merge: fires with the growing FULL previewText and no version arg; is throttled (~250ms per file); is skipped for non-markdown files and for a base-less append (the delete-everything wipe guard); and runs at most one-in-flight per file. Verified to fail if any gate/guard is removed. * fix(collab-doc): coordinate live-doc merge ordering in one place; close durable-clobber race The second review found a residual: the durable edit_content write went through a different path than the adapter's in-flight guard, so a late straggler streaming merge could land after it and, via a persist, clobber the durable file's tail. Move the per-file coordination into mergeEditIntoLiveFileDoc (the one place both the streaming and durable paths call): a streaming (versionless) merge is dropped while one is in flight for the file; a durable (versioned) write instead WAITS for the in-flight streaming merge, so the final content is always the last merge applied and can't be regressed by a straggler. Simplifies the adapter (drops its Set + helper). Relocate the one-in-flight test to notify.test.ts (streaming-drops-while-busy + durable-waits-then-applies-last); the adapter test keeps throttle/gates/previewText. * fix(copilot): address review — order merges, exclude update, gate throttle, unhide stream Review round on #6108: - Greptile P1 (durable merges lose ordering): serialize ALL merges per file on one chain in mergeEditIntoLiveFileDoc (each chains after the current tail), so concurrent durable writes can't resume-and-fire out of order. notify now exposes isLiveDocMergeInFlight. - Cursor High (update stream blanks the doc): only append/patch stream — they build on the loaded base; update is a from-scratch rewrite whose partial snapshot would diff the full doc toward a fragment, so it applies atomically at the durable write. - Cursor Medium (throttle advances on a dropped merge): the adapter gates on !isLiveDocMergeInFlight, so the send throttle advances only on an actual dispatch — no lag, no backlog behind a slow relay. - Cursor Medium (placeholder hides a live stream): show the fast-render placeholder only when not streaming, so a stream that starts before the doc seeds shows through the editor. - Soften merge.ts/notify.ts comments per the lifecycle audit: only UNTOUCHED regions are preserved; a region the merge rewrites reconciles toward copilot's content. Tests updated: notify covers chain ordering + isLiveDocMergeInFlight; adapter covers append streaming, throttle, non-markdown/base-less/update skips, and the in-flight skip. * fix(collab-doc): reject stale durable merges at the relay (cross-process ordering) The in-process merge chain only orders merges within one apps/sim process. Two durable writes for the same file on DIFFERENT processes could reach the relay out of dispatch order; the relay recorded the version monotonically but still APPLIED the older markdown, regressing the live doc while the token stayed high (a later persist could then write the stale content back over the durable file). Enforce ordering at the relay — the single cross-process coordination point — using the existing Redis primitives: under the per-file Redis merge lock, read the cluster-wide synced version and SKIP a versioned merge that is not newer (a newer durable write already landed). Make recordVersion await setSyncedVersion so it is durable before the lock releases, so the next holder's staleness check reads a consistent value. Streaming (versionless) merges are unaffected — they carry no durable version and are ordered per-process by the caller. Adds a relay test asserting a stale/idempotent versioned merge returns 'stale' and never computes or publishes a diff. * fix(copilot): match durable path — detect markdown by MIME type + name at the stream gate The streaming gate checked isMarkdownFile with only the filename, while the durable merge uses type + name — so a text/markdown file without a .md extension was skipped mid-stream (it self-corrected at the durable write). Pass editIntent.contentType so streaming detects the same set of markdown files as the durable path. * test(copilot): assert throttle follow-through after an in-flight merge clears * fix(collab-doc): order streaming merges by streamedAt so a late snapshot can't regress a newer durable write * refactor(collab-doc): tidy merge-order docs + relay order object; cover multi-replica streaming stale-check * fix(collab-doc): order streaming merges by causal base version, not wall-clock A streaming snapshot now carries baseVersion (the durable contentUpdatedAt it was built from) instead of a wall-clock streamedAt. The relay drops the snapshot when a newer durable write landed since that base, so a concurrent human save can no longer be clobbered in the live doc and then persisted over the durable file. Skew-immune: both keys are DB-monotonic contentUpdatedAt values. * fix(collab-doc): derive streaming baseVersion as contentUpdatedAt ?? updatedAt Match the version line the seed/persist use so a legacy file with no content version still ships an ordered streaming snapshot instead of an unordered one. * fix(collab-doc): fail-closed on a streaming snapshot with no baseVersion The live-merge gate now requires a numeric baseVersion, not just loaded base content. A rare base with no file record (hence no version) would otherwise ship an unordered snapshot the relay can't stale-check, risking a clobber of a concurrent durable write. Skip the live merge instead; the durable write reconciles. * docs(collab-doc): document the accepted concurrent-independent-streams limitation
1 parent f07071c commit 630e7cb

14 files changed

Lines changed: 800 additions & 58 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Multi-replica (store-enabled) coverage for the copilot live-merge stale-check. The main
5+
* `file-doc.test.ts` runs with the store DISABLED (single-replica fallback); this file mocks an ENABLED
6+
* store so the cross-process branch of `mergeMarkdownIntoRoom` — staleness against the SHARED synced
7+
* version under the merge lock, and `recordVersion` writing `setSyncedVersion` — is exercised directly.
8+
* The enabled merge path reads its base from the shared store (not an in-memory room), so no JOIN/seed
9+
* is needed: calling `applyMarkdownToLiveFileDoc` against the fake store drives the branch on its own.
10+
*/
11+
import { beforeEach, describe, expect, it, vi } from 'vitest'
12+
import * as Y from 'yjs'
13+
14+
const { mockFetchFileDocMerge } = vi.hoisted(() => ({
15+
mockFetchFileDocMerge: vi.fn(),
16+
}))
17+
18+
/**
19+
* A minimal ENABLED store: in-memory monotonic synced version (mirrors SET_VERSION_IF_NEWER_SCRIPT), a
20+
* non-null stream state so the merge has a base, and no-op locks/publish. Only the surface the
21+
* store-enabled merge path touches is implemented.
22+
*/
23+
const fakeStore = {
24+
enabled: true,
25+
versions: new Map<string, number>(),
26+
acquireMergeSlot: vi.fn(async () => 'token'),
27+
releaseMergeSlot: vi.fn(async () => {}),
28+
getStreamState: vi.fn(async () => new Uint8Array([1])),
29+
publishAndWait: vi.fn(async () => {}),
30+
getSyncedVersion: vi.fn(async (name: string) => fakeStore.versions.get(name) ?? null),
31+
setSyncedVersion: vi.fn(async (name: string, version: number) => {
32+
fakeStore.versions.set(name, Math.max(fakeStore.versions.get(name) ?? 0, version))
33+
}),
34+
}
35+
36+
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() }))
37+
38+
vi.mock('@/handlers/file-doc-app', () => ({
39+
fetchFileDocSeed: vi.fn(),
40+
fetchFileDocMerge: mockFetchFileDocMerge,
41+
fetchFileDocPersist: vi.fn(),
42+
}))
43+
44+
vi.mock('@/handlers/file-doc-store', () => ({
45+
getFileDocStore: () => fakeStore,
46+
REDIS_ORIGIN: Symbol('redis'),
47+
REDIS_SNAPSHOT_ORIGIN: Symbol('redis-snapshot'),
48+
}))
49+
50+
import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc'
51+
52+
const ROOM_NAME = 'workspace-file-doc:file-1'
53+
54+
describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
fakeStore.versions.clear()
58+
fakeStore.acquireMergeSlot.mockResolvedValue('token')
59+
fakeStore.getStreamState.mockResolvedValue(new Uint8Array([1]))
60+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
61+
})
62+
63+
it('drops a stale-base streaming snapshot against the SHARED synced version and never records it', async () => {
64+
// A durable write (e.g. a concurrent human save on another process) records the shared synced version.
65+
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe(
66+
'applied'
67+
)
68+
expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100)
69+
mockFetchFileDocMerge.mockClear()
70+
71+
// A streaming snapshot built from an older base (50) than the SHARED synced version is stale —
72+
// rejected under the lock before any diff is built, so it can't clobber the durable write.
73+
expect(
74+
await applyMarkdownToLiveFileDoc('file-1', '# stale-base stream', { baseVersion: 50 })
75+
).toBe('stale')
76+
expect(mockFetchFileDocMerge).not.toHaveBeenCalled()
77+
78+
// A streaming snapshot whose base is the current shared version applies (nothing newer to clobber)...
79+
expect(
80+
await applyMarkdownToLiveFileDoc('file-1', '# current-base stream', { baseVersion: 100 })
81+
).toBe('applied')
82+
// ...but is never recorded: a later durable write at 150 still applies.
83+
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe(
84+
'applied'
85+
)
86+
expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150)
87+
// setSyncedVersion fired only for the two durable writes, never for a streaming snapshot.
88+
expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2)
89+
})
90+
})

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,65 @@ describe('setupWorkspaceFileDocHandlers', () => {
543543
expect(mockFetchFileDocMerge).not.toHaveBeenCalled()
544544
})
545545

546+
it('rejects a stale versioned merge (not newer than the synced version) without regressing the doc', async () => {
547+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) // seed version 1
548+
const { io } = createIo()
549+
const { handlers } = setup('socket-1', io)
550+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
551+
await flushMicrotasks()
552+
553+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
554+
555+
// A newer durable version lands and is recorded as the synced version.
556+
expect(await applyMarkdownToLiveFileDoc('file-1', '# newer', { version: 100 })).toBe('applied')
557+
mockFetchFileDocMerge.mockClear()
558+
559+
// An older durable version arriving out of order (e.g. a concurrent write on another process) is
560+
// stale: skipped before any diff is computed, so the live doc never regresses to older content and
561+
// no diff is published that a later persist could write back.
562+
expect(await applyMarkdownToLiveFileDoc('file-1', '# older, stale', { version: 50 })).toBe(
563+
'stale'
564+
)
565+
// The same version is idempotent — also skipped.
566+
expect(await applyMarkdownToLiveFileDoc('file-1', '# same version', { version: 100 })).toBe(
567+
'stale'
568+
)
569+
expect(mockFetchFileDocMerge).not.toHaveBeenCalled()
570+
})
571+
572+
it('drops a streaming snapshot whose base predates a newer durable write, but never records it', async () => {
573+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original')) // seed version 1
574+
const { io } = createIo()
575+
const { handlers } = setup('socket-1', io)
576+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
577+
await flushMicrotasks()
578+
579+
mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc()))
580+
581+
// A durable write (e.g. a concurrent human save) lands and is recorded as the synced version.
582+
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe(
583+
'applied'
584+
)
585+
586+
// A streaming snapshot built from an OLDER base (50) — copilot loaded the file before that durable
587+
// write — is stale: applying it would diff the live doc back toward the copilot content and clobber
588+
// the durable write, which a later persist would then write over the file.
589+
expect(
590+
await applyMarkdownToLiveFileDoc('file-1', '# stale-base stream', { baseVersion: 50 })
591+
).toBe('stale')
592+
593+
// A streaming snapshot whose base IS the current durable version applies — nothing newer to clobber.
594+
expect(
595+
await applyMarkdownToLiveFileDoc('file-1', '# current-base stream', { baseVersion: 100 })
596+
).toBe('applied')
597+
598+
// ...and a streaming merge is never recorded as the synced version: a later durable write at 150 still
599+
// applies (only durable writes move the synced version; the final edit_content write reconciles).
600+
expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe(
601+
'applied'
602+
)
603+
})
604+
546605
it('serializes concurrent merges for the same file (second waits for the first)', async () => {
547606
mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original'))
548607
const { io } = createIo()

apps/realtime/src/handlers/file-doc.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,16 @@ function emptySeedUpdate(): Uint8Array {
515515
/** Serializes live merges per file so overlapping calls never race the same doc (see below). */
516516
const fileDocMergeChains = new Map<string, Promise<unknown>>()
517517

518+
/**
519+
* How a merge is positioned on the file's version line — mirrors the sim-side `LiveFileDocMergeOrder`
520+
* wire fields. A durable `version` is checked AND recorded; a streaming `baseVersion` (the durable version
521+
* the snapshot was built from) is checked only — dropped if a newer durable write has since landed.
522+
*/
523+
interface MergeOrder {
524+
version?: number
525+
baseVersion?: number
526+
}
527+
518528
/**
519529
* Apply new markdown into a file's LIVE collaborative document (Stage C — copilot writing into an open
520530
* doc). Ships the document's current state to the app to build a minimal Yjs diff, applies it — which
@@ -540,14 +550,12 @@ const fileDocMergeChains = new Map<string, Promise<unknown>>()
540550
export function applyMarkdownToLiveFileDoc(
541551
fileId: string,
542552
markdown: string,
543-
version?: number
544-
): Promise<'applied' | 'no-live-room' | 'merge-unavailable'> {
553+
order: MergeOrder = {}
554+
): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> {
545555
const name = roomName(fileDocRoom(fileId))
546556
const prior = fileDocMergeChains.get(name) ?? Promise.resolve()
547557
// `.catch` so a failed prior merge doesn't reject this one — each merge is independent.
548-
const run = prior
549-
.catch(() => {})
550-
.then(() => mergeMarkdownIntoRoom(name, fileId, markdown, version))
558+
const run = prior.catch(() => {}).then(() => mergeMarkdownIntoRoom(name, fileId, markdown, order))
551559
fileDocMergeChains.set(
552560
name,
553561
run.finally(() => {
@@ -561,21 +569,51 @@ async function mergeMarkdownIntoRoom(
561569
name: string,
562570
fileId: string,
563571
markdown: string,
564-
version?: number
565-
): Promise<'applied' | 'no-live-room' | 'merge-unavailable'> {
572+
{ version, baseVersion }: MergeOrder
573+
): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> {
566574
const store = getFileDocStore()
567575

568576
// The durable version this merge carries is now incorporated in the live doc — record it (cluster-wide
569577
// in Redis for multi-task, plus this task's room) so the persist If-Match guard treats this write as
570-
// synced rather than an out-of-band conflict. Set on success below.
571-
const recordVersion = () => {
578+
// synced rather than an out-of-band conflict. AWAITED so the version is durable before the merge lock
579+
// releases, so the next lock holder's staleness check (below) reads a consistent value. Only a durable
580+
// `version` is recorded — a streaming `baseVersion` orders the merge (below) but is never a checkpoint,
581+
// so the synced version stays pinned to the last durable write.
582+
const recordVersion = async () => {
572583
if (version === undefined) return
573584
const room = fileDocRooms.get(name)
574-
// Never regress the token: merges/seeds/persists all write it (locally and via fire-and-forget
575-
// Redis), so a lower value arriving out of order must not shadow a higher one the doc already
576-
// incorporates (the Redis side is guarded identically by SET_VERSION_IF_NEWER_SCRIPT).
585+
// Never regress the token: merges/seeds/persists all write it, so a lower value arriving out of
586+
// order must not shadow a higher one the doc already incorporates (the Redis side is guarded
587+
// identically by SET_VERSION_IF_NEWER_SCRIPT).
577588
if (room) room.syncedVersion = Math.max(room.syncedVersion ?? 0, version)
578-
void store.setSyncedVersion(name, version)
589+
await store.setSyncedVersion(name, version)
590+
}
591+
592+
// Order this merge on the file's version line, where `current` is the durable version the doc already
593+
// incorporates. Both keys are DB-monotonic `contentUpdatedAt` values (no wall-clock), so ordering is
594+
// immune to clock skew:
595+
// - A durable `version` is stale if it is NOT strictly newer than `current` — a newer durable write
596+
// already landed (possibly on another process, out of dispatch order); applying its older markdown
597+
// would regress the doc while the monotonic token stays high.
598+
// - A streaming `baseVersion` (the durable version the snapshot was built from) is stale if `current`
599+
// has moved PAST it — a newer durable write landed since the snapshot's base, so diffing the live
600+
// doc back toward the snapshot would clobber that write's content (which a later persist, still
601+
// holding the current If-Match token, would then write over the durable file). This is what stops a
602+
// concurrent human edit from being silently lost; the per-process caller chain cannot see it.
603+
// A merge with neither key is never stale (legacy, unordered). Only a durable `version` is recorded,
604+
// so a streaming snapshot never advances the synced version — the final `edit_content` write does.
605+
//
606+
// Known, accepted limitation: two INDEPENDENT copilot streams editing the SAME file at once share one
607+
// base version, so neither is stale relative to the other and their snapshots can interleave in the live
608+
// doc. This is transient only — each stream's final durable write is version-ordered and reconciles the
609+
// doc, so the steady state is deterministic (last durable wins) and the durable file is never corrupted.
610+
// Ordering two independent snapshot streams would need a shared sequence they don't have; the fully
611+
// robust form (a per-file streaming lease, or embedding the version in each stream entry) is a scoped
612+
// follow-up, not a durability fix owed here.
613+
const isStale = (current: number): boolean => {
614+
if (version !== undefined) return version <= current
615+
if (baseVersion !== undefined) return current > baseVersion
616+
return false
579617
}
580618

581619
if (store.enabled) {
@@ -595,6 +633,11 @@ async function mergeMarkdownIntoRoom(
595633
return 'merge-unavailable'
596634
}
597635
try {
636+
// Staleness is checked under the lock against the cluster-wide synced version, so a durable merge
637+
// that lost the race to a newer one (on any process) is dropped rather than regressing the doc.
638+
const shared = await store.getSyncedVersion(name)
639+
const current = Math.max(shared ?? 0, fileDocRooms.get(name)?.syncedVersion ?? 0)
640+
if (isStale(current)) return 'stale'
598641
// Compute the diff against the committed SHARED state and PUBLISH it — every task with the doc
599642
// live (including this one, via its own tailer) applies it and fans it out to its clients, so the
600643
// merge reaches the live doc no matter which task the apply-edit call landed on. An empty stream
@@ -604,7 +647,7 @@ async function mergeMarkdownIntoRoom(
604647
if (!base) return 'no-live-room'
605648
const diff = await fetchFileDocMerge(fileId, base, markdown)
606649
await store.publishAndWait(name, diff)
607-
recordVersion()
650+
await recordVersion()
608651
return 'applied'
609652
} finally {
610653
await store.releaseMergeSlot(name, token)
@@ -614,12 +657,13 @@ async function mergeMarkdownIntoRoom(
614657
// Single-replica fallback: apply straight to the local authoritative doc.
615658
const room = fileDocRooms.get(name)
616659
if (!room || room.owners.size === 0 || !isDocSeeded(room.doc)) return 'no-live-room'
660+
if (isStale(room.syncedVersion ?? 0)) return 'stale'
617661
const update = await fetchFileDocMerge(fileId, Y.encodeStateAsUpdate(room.doc), markdown)
618662
// The room may have been dropped while the diff was being built; never touch a destroyed doc.
619663
if (fileDocRooms.get(name) !== room) return 'no-live-room'
620664
// No transaction origin → `doc.on('update')` relays to the WHOLE room (every editor sees copilot).
621665
Y.applyUpdate(room.doc, update)
622-
recordVersion()
666+
await recordVersion()
623667
return 'applied'
624668
}
625669

apps/realtime/src/routes/http.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,17 +209,17 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
209209
if (req.method === 'POST' && req.url === '/api/file-doc/apply-edit') {
210210
try {
211211
const body = await readRequestBody(req)
212-
const { fileId, markdown, version } = JSON.parse(body)
212+
const { fileId, markdown, version, baseVersion } = JSON.parse(body)
213213
if (!isNonEmptyString(fileId) || typeof markdown !== 'string') {
214214
return sendError(res, 'Invalid fileId or markdown', 400)
215215
}
216216
// `version` (the durable updatedAt this markdown was written with) records that the live doc now
217217
// incorporates that durable version, so the persist If-Match guard won't flag it as a conflict.
218-
const result = await applyMarkdownToLiveFileDoc(
219-
fileId,
220-
markdown,
221-
typeof version === 'number' ? version : undefined
222-
)
218+
// `baseVersion` is a streaming snapshot's causal base: dropped if a newer durable write landed.
219+
const result = await applyMarkdownToLiveFileDoc(fileId, markdown, {
220+
version: typeof version === 'number' ? version : undefined,
221+
baseVersion: typeof baseVersion === 'number' ? baseVersion : undefined,
222+
})
223223
res.writeHead(200, { 'Content-Type': 'application/json' })
224224
res.end(JSON.stringify({ applied: result === 'applied' }))
225225
} catch (error) {

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { cn, toast } from '@sim/emcn'
55
import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc'
6-
import type { Extensions, JSONContent } from '@tiptap/core'
6+
import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core'
77
import { isChangeOrigin } from '@tiptap/extension-collaboration'
88
import { Fragment, Slice } from '@tiptap/pm/model'
99
import { NodeSelection } from '@tiptap/pm/state'
@@ -297,6 +297,18 @@ export function LoadedRichMarkdownEditor({
297297
? ''
298298
: parseMarkdownToDoc(splitFrontmatter(content).body)
299299
)
300+
/**
301+
* A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits
302+
* for its server seed, so the pane shows content instantly instead of blocking blank on the socket
303+
* round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static
304+
* HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which
305+
* is the invariant that keeps seeding out of the client (a client seed duplicates the doc).
306+
*/
307+
const [placeholderHtml] = useState<string | null>(() =>
308+
collaborationEnabled
309+
? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS)
310+
: null
311+
)
300312
/**
301313
* The body currently shown in the editor: seeded from a settled mount, updated on local edits (via
302314
* onUpdate) and on each streamed sync. Incremental edits (append/patch) stream complete snapshots and
@@ -923,6 +935,11 @@ export function LoadedRichMarkdownEditor({
923935
[]
924936
)
925937

938+
// Show the read-only placeholder only for a plain cold open — never during an agent stream. A stream
939+
// that begins before the doc has seeded fills the (hidden) editor via Yjs, so gating the placeholder
940+
// off while streaming lets that live content show through instead of hiding it behind stale markdown.
941+
const showPlaceholder = collaborationEnabled && !collabReady && !isStreaming
942+
926943
return (
927944
<div
928945
ref={containerRef}
@@ -947,9 +964,20 @@ export function LoadedRichMarkdownEditor({
947964
if (images.length > 0) void insertImagesRef.current(images, at)
948965
}}
949966
/>
967+
{showPlaceholder && placeholderHtml && (
968+
// Instant read-only content while the collaborative doc seeds; the editor stays mounted-but-
969+
// hidden below so it renders the seeded doc before the swap. Same layout box → no reflow.
970+
<div
971+
className='rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6'
972+
dangerouslySetInnerHTML={{ __html: placeholderHtml }}
973+
/>
974+
)}
950975
<EditorContent
951976
editor={editor}
952-
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
977+
className={cn(
978+
'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white',
979+
showPlaceholder && placeholderHtml && 'hidden'
980+
)}
953981
/>
954982
</div>
955983
)

0 commit comments

Comments
 (0)