From f29c337fb7c2fc78ec052e388e07771ed21374dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 16:38:05 -0700 Subject: [PATCH 01/13] feat(files): client Yjs provider for collaborative document editing [5/N] Custom Socket.IO Yjs provider (the client half of the file-doc relay merged in #5941): the Socket.IO analogue of y-websocket's WebsocketProvider, speaking the same y-protocols sync + awareness so TipTap's Collaboration/CollaborationCaret work unmodified. Binds its clientId at join, exchanges sync only after the join ack, publishes only its own awareness, re-syncs on reconnect, and detaches all listeners on destroy. 14 tests. Deps: yjs + y-protocols + lib0 + @tiptap/extension-collaboration + -collaboration-caret. Editor integration (wiring into the rich-markdown editor + seeding + CSS) follows in this branch. --- .../collaboration/file-doc-provider.test.ts | 249 ++++++++++++++++++ .../collaboration/file-doc-provider.ts | 222 ++++++++++++++++ apps/sim/package.json | 5 + bun.lock | 11 + 4 files changed, 487 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts new file mode 100644 index 00000000000..a1af842f73b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -0,0 +1,249 @@ +/** + * @vitest-environment node + */ +import { FILE_DOC_EVENTS, FILE_DOC_MESSAGE_TYPE } from '@sim/realtime-protocol/file-doc' +import * as encoding from 'lib0/encoding' +import type { Socket } from 'socket.io-client' +import { describe, expect, it, vi } from 'vitest' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' +import { FileDocProvider } from './file-doc-provider' + +/** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ +function createSocket(connected = true) { + const listeners = new Map void>>() + const emit = vi.fn() + const socket = { + connected, + emit, + on(event: string, cb: (...args: unknown[]) => void) { + let set = listeners.get(event) + if (!set) { + set = new Set() + listeners.set(event, set) + } + set.add(cb) + }, + off(event: string, cb: (...args: unknown[]) => void) { + listeners.get(event)?.delete(cb) + }, + } + const fire = (event: string, ...args: unknown[]) => { + for (const cb of listeners.get(event) ?? []) cb(...args) + } + return { socket: socket as unknown as Socket, emit, fire } +} + +function createProvider(connected = true) { + const { socket, emit, fire } = createSocket(connected) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + return { provider, doc, awareness, emit, fire } +} + +/** Messages emitted to the server, decoded to their `{ type, bytes }`. */ +function emittedMessages(emit: ReturnType) { + return emit.mock.calls + .filter( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + .map(([, payload]) => payload as Uint8Array) +} + +describe('FileDocProvider', () => { + it('joins immediately with its client id when the socket is already connected', () => { + const { doc, emit } = createProvider(true) + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, { + fileId: 'file-1', + clientId: doc.clientID, + }) + }) + + it('waits for connect before joining when the socket is offline', () => { + const { emit, fire } = createProvider(false) + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + fire('connect') + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('exchanges sync only after JOIN_SUCCESS', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + + // A sync step 1 (type tag 0) is sent to exchange state with the server. + const messages = emittedMessages(emit) + expect(messages.length).toBeGreaterThan(0) + expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('emits seed-request when the server elects it to seed', () => { + const { provider, fire } = createProvider(true) + const seed = vi.fn() + provider.on('seed-request', seed) + + fire(FILE_DOC_EVENTS.SEED_REQUEST, { fileId: 'file-1' }) + + expect(seed).toHaveBeenCalledTimes(1) + }) + + it('ignores acks and seed requests for a different file', () => { + const { provider, emit, fire } = createProvider(true) + const seed = vi.fn() + provider.on('seed-request', seed) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'other-file' }) + fire(FILE_DOC_EVENTS.SEED_REQUEST, { fileId: 'other-file' }) + + expect(seed).not.toHaveBeenCalled() + expect(emittedMessages(emit)).toHaveLength(0) + }) + + it('applies a server sync step 2 and becomes synced', () => { + const { provider, doc, fire } = createProvider(true) + const synced = vi.fn() + provider.on('synced', synced) + + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'hello world') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + expect(doc.getText('default').toString()).toBe('hello world') + expect(provider.synced).toBe(true) + expect(synced).toHaveBeenCalledWith(true) + }) + + it('sends local document edits to the server as sync updates', () => { + const { doc, emit } = createProvider(true) + emit.mockClear() + + doc.getText('default').insert(0, 'x') + + const messages = emittedMessages(emit) + expect(messages.length).toBe(1) + expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) + }) + + it('does not echo updates it applied from the server', () => { + const { provider, emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' }) + emit.mockClear() + + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'remote') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(serverDoc)) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + // The applied remote update must not be re-emitted back to the server. + expect(emittedMessages(emit)).toHaveLength(0) + expect(provider.doc.getText('default').toString()).toBe('remote') + }) + + it('sends local awareness (cursor/selection) changes', () => { + const { awareness, emit } = createProvider(true) + emit.mockClear() + + awareness.setLocalStateField('user', { name: 'Ada', color: '#f783ac' }) + + const messages = emittedMessages(emit) + expect(messages.some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(true) + }) + + it('does not forward awareness it applied from the server', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + const remoteDoc = new Y.Doc() + remoteDoc.clientID = 8888 + const remoteAwareness = new awarenessProtocol.Awareness(remoteDoc) + remoteAwareness.setLocalStateField('user', { name: 'Remote' }) + const update = awarenessProtocol.encodeAwarenessUpdate(remoteAwareness, [8888]) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array(encoder, update) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + + // The remote peer's awareness (client 8888, not ours) must not be re-published. + expect(emittedMessages(emit).some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(false) + }) + + it('stops attempting to join after a non-retryable error', () => { + const { emit, fire } = createProvider(true) + emit.mockClear() + + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Access denied', + code: 'ACCESS_DENIED', + retryable: false, + }) + fire('connect') + + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + }) + + it('still rejoins on reconnect after a retryable error', () => { + const { emit, fire } = createProvider(true) + fire(FILE_DOC_EVENTS.JOIN_ERROR, { + fileId: 'file-1', + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + emit.mockClear() + + fire('connect') + + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('resets synced and rejoins on a reconnect', () => { + const { provider, emit, fire } = createProvider(true) + // Become synced. + const serverDoc = new Y.Doc() + serverDoc.getText('default').insert(0, 'hi') + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep2(encoder, serverDoc) + fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + expect(provider.synced).toBe(true) + emit.mockClear() + + // A reconnect must drop synced and re-issue JOIN so the doc re-syncs. + fire('connect') + + expect(provider.synced).toBe(false) + expect(emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN, + expect.objectContaining({ fileId: 'file-1' }) + ) + }) + + it('leaves the room and detaches on destroy', () => { + const { provider, doc, emit } = createProvider(true) + emit.mockClear() + + provider.destroy() + + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-1' }) + // After destroy, local edits are no longer forwarded. + emit.mockClear() + doc.getText('default').insert(0, 'y') + expect(emittedMessages(emit)).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts new file mode 100644 index 00000000000..afc281e1c91 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -0,0 +1,222 @@ +import { + FILE_DOC_EVENTS, + FILE_DOC_MESSAGE_TYPE, + type JoinFileDocError, + type JoinFileDocSuccess, + type SeedRequestPayload, + toFileDocBytes, +} from '@sim/realtime-protocol/file-doc' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import { ObservableV2 } from 'lib0/observable' +import type { Socket } from 'socket.io-client' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import type * as Y from 'yjs' + +/** + * Events emitted by {@link FileDocProvider}. + * - `synced`: the first full document sync with the server completed. + * - `seed-request`: the server elected this client to seed the document's initial + * content from the file's stored markdown (the editor does the import, guarded + * by the CRDT `initialContentLoaded` flag). + * - `join-error`: the server rejected the join (e.g. lost write access). + */ +interface FileDocProviderEvents { + synced: (synced: boolean) => void + 'seed-request': () => void + 'join-error': (error: JoinFileDocError) => void +} + +/** + * The client half of the collaborative file-document protocol: a Yjs provider + * that carries document sync + awareness over the shared, already-authenticated + * Socket.IO connection (the server relay lives in + * `apps/realtime/src/handlers/file-doc.ts`). It is the Socket.IO analogue of + * `y-websocket`'s `WebsocketProvider` — the same `y-protocols` message framing — + * so TipTap's `Collaboration` (bound to {@link doc}) and `CollaborationCaret` + * (bound to this provider's {@link awareness}) work unmodified. + * + * The document and awareness are owned by the caller (the hook) and are NOT + * destroyed here, so the provider can be torn down and rebuilt (e.g. on a socket + * reconnect) without discarding local edits. + */ +export class FileDocProvider extends ObservableV2 { + synced = false + + private disposed = false + /** Set on a non-retryable join rejection (e.g. lost write access) so the + * provider stops attempting to (re)join until the owner tears it down. */ + private fatal = false + + constructor( + private readonly socket: Socket, + private readonly fileId: string, + readonly doc: Y.Doc, + readonly awareness: awarenessProtocol.Awareness + ) { + super() + + socket.on(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) + socket.on(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + socket.on(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) + socket.on(FILE_DOC_EVENTS.SEED_REQUEST, this.handleSeedRequest) + socket.on('connect', this.handleConnect) + doc.on('update', this.handleDocUpdate) + awareness.on('update', this.handleAwarenessUpdate) + + if (socket.connected) this.join() + } + + private join = () => { + if (this.fatal) return + // Bind our client id at join so the server only accepts awareness we own. + this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, clientId: this.doc.clientID }) + } + + /** + * Re-join after a (re)connect. The server re-registers the room before acking, + * so the sync/awareness exchange is deferred to {@link handleJoinSuccess}. + */ + private handleConnect = () => { + if (this.fatal) return + this.setSynced(false) + this.join() + } + + private handleJoinSuccess = (data: JoinFileDocSuccess) => { + if (data.fileId !== this.fileId) return + // We are now a member of the room, so it is safe to send messages (an earlier + // send could be dropped: the server registers the room before this ack). + this.sendSyncStep1() + this.sendLocalAwareness() + } + + private handleJoinError = (data: JoinFileDocError) => { + if (data.fileId !== this.fileId) return + // A non-retryable rejection (access denied, invalid) won't succeed on retry; + // stop (re)joining and let the owner fall back to the non-collaborative view. + if (data.retryable === false) this.fatal = true + this.emit('join-error', [data]) + } + + private handleSeedRequest = (data: SeedRequestPayload) => { + if (data.fileId !== this.fileId) return + this.emit('seed-request', []) + } + + private handleMessage = (data: unknown) => { + const bytes = toFileDocBytes(data) + if (!bytes) return + + const decoder = decoding.createDecoder(bytes) + const messageType = decoding.readVarUint(decoder) + + switch (messageType) { + case FILE_DOC_MESSAGE_TYPE.SYNC: { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + // `this` is the transaction origin, so our own `doc.on('update')` skips + // re-sending updates we just applied from the server. + const syncType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this) + if (encoding.length(encoder) > 1) { + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + // The first sync step 2 marks the document fully synced with the server. + if (syncType === syncProtocol.messageYjsSyncStep2 && !this.synced) this.setSynced(true) + break + } + case FILE_DOC_MESSAGE_TYPE.AWARENESS: { + awarenessProtocol.applyAwarenessUpdate( + this.awareness, + decoding.readVarUint8Array(decoder), + this + ) + break + } + } + } + + private handleDocUpdate = (update: Uint8Array, origin: unknown) => { + // Updates we applied from the server carry `this` as origin — don't echo them. + if (origin === this) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeUpdate(encoder, update) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private handleAwarenessUpdate = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown + ) => { + // Only ever publish OUR OWN awareness. Remote changes (origin === this) were + // applied from the server; and a local `Awareness` also emits 30s `timeout` + // removals for remote peers — forwarding either would be a frame for a client + // id we don't own, which the server (correctly) rejects. Filter to our own id + // so honest traffic never trips the ownership guard. + if (origin === this) return + const localId = this.doc.clientID + const changed = [...added, ...updated, ...removed].filter((id) => id === localId) + if (changed.length === 0) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed) + ) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private sendSyncStep1() { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) + syncProtocol.writeSyncStep1(encoder, this.doc) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private sendLocalAwareness() { + if (this.awareness.getLocalState() === null) return + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.AWARENESS) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID]) + ) + this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) + } + + private setSynced(synced: boolean) { + if (this.synced === synced) return + this.synced = synced + this.emit('synced', [synced]) + } + + /** + * Tear down the provider: leave the room, clear our awareness (so our caret + * disappears for everyone else), and detach all listeners. The document and + * awareness objects are the caller's and are left intact. + */ + destroy() { + if (this.disposed) { + super.destroy() + return + } + this.disposed = true + + // Clear our local awareness so peers drop our caret immediately, rather than + // waiting for the server's 30s awareness timeout. + awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy') + + this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) + this.socket.off(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) + this.socket.off(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) + this.socket.off(FILE_DOC_EVENTS.SEED_REQUEST, this.handleSeedRequest) + this.socket.off('connect', this.handleConnect) + this.doc.off('update', this.handleDocUpdate) + this.awareness.off('update', this.handleAwarenessUpdate) + + super.destroy() + } +} diff --git a/apps/sim/package.json b/apps/sim/package.json index f0aaa82fa3a..6914862cd0f 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -118,6 +118,8 @@ "@tanstack/react-virtual": "3.13.24", "@tiptap/core": "3.26.1", "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-collaboration": "3.26.1", + "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-image": "3.26.1", "@tiptap/extension-list": "3.26.1", "@tiptap/extension-placeholder": "3.26.1", @@ -170,6 +172,7 @@ "js-yaml": "4.3.0", "json5": "2.2.3", "jszip": "3.10.1", + "lib0": "0.2.117", "lru-cache": "11.3.6", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", @@ -222,6 +225,8 @@ "undici": "7.28.0", "unpdf": "1.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6", "zustand": "^5.0.13" }, diff --git a/bun.lock b/bun.lock index f1e8d8bde90..9fe1aa4b52c 100644 --- a/bun.lock +++ b/bun.lock @@ -189,6 +189,8 @@ "@tanstack/react-virtual": "3.13.24", "@tiptap/core": "3.26.1", "@tiptap/extension-code-block": "3.26.1", + "@tiptap/extension-collaboration": "3.26.1", + "@tiptap/extension-collaboration-caret": "3.26.1", "@tiptap/extension-image": "3.26.1", "@tiptap/extension-list": "3.26.1", "@tiptap/extension-placeholder": "3.26.1", @@ -241,6 +243,7 @@ "js-yaml": "4.3.0", "json5": "2.2.3", "jszip": "3.10.1", + "lib0": "0.2.117", "lru-cache": "11.3.6", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", @@ -293,6 +296,8 @@ "undici": "7.28.0", "unpdf": "1.4.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "y-protocols": "1.0.7", + "yjs": "13.6.31", "zod": "4.3.6", "zustand": "^5.0.13", }, @@ -1774,6 +1779,10 @@ "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ=="], + "@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5", "yjs": "^13" } }, "sha512-NLF3tWPla1bg9VAaICTrheEjDi9ZFGk1HhoALfVHWSwIqnUpVxSubyBxw/PFWyHYZNQrxaGvypf457NHHINolA=="], + + "@tiptap/extension-collaboration-caret": ["@tiptap/extension-collaboration-caret@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1", "@tiptap/y-tiptap": "^3.0.5" } }, "sha512-fJpIO8eXYksiTyxck3e7vS2Fsddo+sJNaQdNHXuDykVZzH4LPXAHyGGdSRrLM4aMUsK1IZh8CPyti1CHwbz7vA=="], + "@tiptap/extension-document": ["@tiptap/extension-document@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1" } }, "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA=="], "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.26.1", "", { "peerDependencies": { "@tiptap/extensions": "3.26.1" } }, "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA=="], @@ -1826,6 +1835,8 @@ "@tiptap/suggestion": ["@tiptap/suggestion@3.26.1", "", { "peerDependencies": { "@tiptap/core": "3.26.1", "@tiptap/pm": "3.26.1" } }, "sha512-Bg8IyuDC92InSPzcHvCT3+ZDCJSMJIEINdFg513RPQzwZTw1dsrU0K00XYcDT6lOhZwLM2IVTiE6sZl2GY25Rg=="], + "@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.7", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], From 480015729d25f1d546f4f1c7f1b25660ceb28928 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 16:59:11 -0700 Subject: [PATCH 02/13] feat(files): wire collaborative editing into the rich-markdown editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the Yjs collaboration layer (Collaboration + CollaborationCaret) into the file editor, live carets + text selection in each user's color, name on hover. - use-file-doc-collaboration hook: owns the per-file Y.Doc + Awareness (created synchronously so the editor binds at mount) + the FileDocProvider (connects async over the shared socket). Gated to an editable, round-trip-safe, non-streaming workspace document with a known user — decided once at mount, since TipTap fixes the extension set at editor creation. - editor-extensions: per-instance Collaboration/CollaborationCaret when collab is on; StarterKit's built-in history disabled ONLY on that path (Yjs owns undo/redo) — the headless markdown round-trip path is untouched. Selection is a translucent tint of the caret color; the manual streaming reconcile loop is bypassed (the Y.Doc is the source of truth). - Seeding: on SEED_REQUEST + synced, setContent(markdown) + the initialContentLoaded flag in ONE Yjs transaction (the relay's exactly-once contract). - Autosave gate: the editor reports synced+seeded up to the parent, which holds the markdown-mirror save until then, so an empty/partially-synced doc can never overwrite the real file. - CSS: caret + name-on-hover label + lighter-tint selection, themed under .rich-markdown-prose. Full editor suite (442 tests) green; apps/sim typecheck clean. Live carets + concurrent-edit convergence need a two-browser verification before merge. --- .../use-file-doc-collaboration.ts | 102 +++++++++++ .../rich-markdown-editor/editor-extensions.ts | 54 +++++- .../rich-markdown-editor/extensions.ts | 8 +- .../rich-markdown-editor.css | 44 +++++ .../rich-markdown-editor.tsx | 160 +++++++++++++++++- .../file-viewer/use-editable-file-content.ts | 10 +- 6 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts new file mode 100644 index 00000000000..aa76665a213 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -0,0 +1,102 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Awareness } from 'y-protocols/awareness' +import * as Y from 'yjs' +import { getUserColor } from '@/lib/workspaces/colors' +import { useSocket } from '@/app/workspace/providers/socket-provider' +import { FileDocProvider } from './file-doc-provider' + +/** The live collaboration binding the editor wires into TipTap's Collaboration + * (the {@link Y.Doc}) and CollaborationCaret (the awareness). */ +export interface FileDocCollaboration { + /** Bound to TipTap's Collaboration extension (created synchronously at mount). */ + doc: Y.Doc + /** Bound to CollaborationCaret (via `{ awareness }`); relayed by the provider. */ + awareness: Awareness + /** + * The realtime provider, or `null` until the socket is available. `doc` and + * `awareness` exist before it connects, so the editor can bind immediately; the + * provider is consumed for seeding events (`synced` / `seed-request`). + */ + provider: FileDocProvider | null + /** Local caret identity for CollaborationCaret: display name + assigned color. */ + user: { name: string; color: string } +} + +interface UseFileDocCollaborationParams { + fileId: string + userId: string + userName: string + /** + * Whether to establish collaboration. Decided once at editor mount — only for a + * live, editable, non-streaming workspace document. When `false` the hook + * returns `null` and the editor stays fully local. + */ + enabled: boolean +} + +/** + * Owns the per-file Yjs document, awareness, and {@link FileDocProvider} for + * collaborative editing. The document + awareness are created once (this hook + * lives inside an editor that is keyed by file id, so one instance == one file) + * and are the stable objects TipTap binds to; the provider connects them to the + * realtime relay over the shared socket. Returns `null` while disabled. + */ +export function useFileDocCollaboration({ + fileId, + userId, + userName, + enabled, +}: UseFileDocCollaborationParams): FileDocCollaboration | null { + const { socket } = useSocket() + + // The Y.Doc + Awareness are the editor's authoritative binding — lazily created + // once and stable for the hook's life (see sim-react-performance: lazy-init ref). + const docRef = useRef(null) + docRef.current ??= new Y.Doc() + const awarenessRef = useRef(null) + awarenessRef.current ??= new Awareness(docRef.current) + + const [provider, setProvider] = useState(null) + + // Declared BEFORE the provider effect so, on unmount, React runs this cleanup + // AFTER the provider effect's cleanup (cleanups run in reverse declaration + // order) — the provider detaches from the doc/awareness before they're destroyed. + useEffect(() => { + const doc = docRef.current + const awareness = awarenessRef.current + return () => { + awareness?.destroy() + doc?.destroy() + } + }, []) + + useEffect(() => { + if (!enabled || !socket) return + // Non-null: both refs are lazily set during render, before any effect runs. + const doc = docRef.current as Y.Doc + const awareness = awarenessRef.current as Awareness + const fileProvider = new FileDocProvider(socket, fileId, doc, awareness) + setProvider(fileProvider) + return () => { + fileProvider.destroy() + setProvider(null) + } + }, [enabled, socket, fileId]) + + const user = useMemo(() => ({ name: userName, color: getUserColor(userId) }), [userName, userId]) + + return useMemo( + () => + enabled + ? { + doc: docRef.current as Y.Doc, + awareness: awarenessRef.current as Awareness, + provider, + user, + } + : null, + [enabled, provider, user] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 601d702c07c..b2e9aaacba2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -1,5 +1,10 @@ import type { Extensions } from '@tiptap/core' +import Collaboration from '@tiptap/extension-collaboration' +import CollaborationCaret from '@tiptap/extension-collaboration-caret' import Placeholder from '@tiptap/extension-placeholder' +import type { Awareness } from 'y-protocols/awareness' +import type * as Y from 'yjs' +import { hexToRgb } from '@/lib/colors' import { BlockMover } from './block-mover' import { CodeBlockWithLanguage } from './code-block' import { CodeBlockHighlight } from './code-highlight' @@ -13,10 +18,20 @@ import { MentionChip } from './mention/mention-chip' import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' import { SlashCommand } from './slash-command/slash-command' +/** Live collaboration binding for the editor. When present, the editor's history + * is Yjs-backed and remote carets/selection render via CollaborationCaret. */ +export interface EditorCollaboration { + doc: Y.Doc + awareness: Awareness + user: { name: string; color: string } +} + interface MarkdownEditorExtensionOptions { placeholder: string /** Renders supported media links as live players beneath a standalone link. Off by default. */ embeds?: boolean + /** When set, wires TipTap Collaboration + CollaborationCaret onto the shared document. */ + collaboration?: EditorCollaboration } /** @@ -32,15 +47,40 @@ interface MarkdownEditorExtensionOptions { export function createMarkdownEditorExtensions({ placeholder, embeds = false, + collaboration, }: MarkdownEditorExtensionOptions): Extensions { return [ - ...createMarkdownContentExtensions({ - codeBlock: CodeBlockWithLanguage, - image: ResizableImage, - mention: MentionChip, - rawHtmlBlock: RawHtmlBlockWithView, - footnoteDef: FootnoteDefWithView, - }), + ...createMarkdownContentExtensions( + { + codeBlock: CodeBlockWithLanguage, + image: ResizableImage, + mention: MentionChip, + rawHtmlBlock: RawHtmlBlockWithView, + footnoteDef: FootnoteDefWithView, + }, + { disableHistory: Boolean(collaboration) } + ), + ...(collaboration + ? [ + Collaboration.configure({ document: collaboration.doc }), + // CollaborationCaret reads only `provider.awareness`; the awareness is + // created synchronously and relayed by the socket provider once connected. + // The default caret + name label already color from `user.color`; only the + // selection is overridden to a lighter translucent tint of the same color. + CollaborationCaret.configure({ + provider: { awareness: collaboration.awareness }, + user: collaboration.user, + selectionRender: (user) => { + const hex = typeof user.color === 'string' ? user.color : '#000000' + const { r, g, b } = hexToRgb(hex) + return { + class: 'collaboration-carets__selection', + style: `background-color: rgba(${r}, ${g}, ${b}, 0.2);`, + } + }, + }), + ] + : []), CodeBlockHighlight, SlashCommand, Mention, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 52e4731f27f..0f83b3a32ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -117,7 +117,10 @@ export interface ContentNodeViews { * registry. The live editor passes the node-view nodes via {@link createMarkdownEditorExtensions}; the * schema and markdown output are identical either way. */ -export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}): Extensions { +export function createMarkdownContentExtensions( + nodeViews: ContentNodeViews = {}, + options: { disableHistory?: boolean } = {} +): Extensions { const codeBlock = (nodeViews.codeBlock ?? MarkdownCodeBlock).configure({ HTMLAttributes: { class: 'code-editor-theme' }, }) @@ -128,6 +131,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} codeBlock: false, code: false, paragraph: false, + // Collaboration provides its own (Yjs-backed) undo/redo — disabling the + // built-in history avoids the two fighting over the shared document. + ...(options.disableHistory ? { undoRedo: false as const } : {}), }), BlockSafeParagraph, InlineCode, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index cfb5e9a2ec3..3c34ef01ea8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -454,3 +454,47 @@ .rich-markdown-field-prose p.is-editor-empty:first-child::before { color: var(--text-muted); } + +/* + * Collaborative carets (TipTap CollaborationCaret). The caret border and the name + * label's background are colored inline from each user's assigned color; the + * selection is a translucent tint of that color (set via selectionRender). The + * name label is hidden until the caret is hovered — a quiet, Google-Docs-style + * presence that doesn't clutter the text. + */ +.rich-markdown-prose .collaboration-carets__caret { + position: relative; + margin-left: -1px; + margin-right: -1px; + border-left-width: 1px; + border-left-style: solid; + border-right-width: 1px; + border-right-style: solid; + word-break: normal; +} + +.rich-markdown-prose .collaboration-carets__label { + position: absolute; + top: -1.4em; + left: -1px; + padding: 0.1rem 0.35rem; + border-radius: 3px 3px 3px 0; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + color: #fff; + user-select: none; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease; +} + +.rich-markdown-prose .collaboration-carets__caret:hover .collaboration-carets__label { + opacity: 1; +} + +.rich-markdown-prose .collaboration-carets__selection { + border-radius: 2px; + pointer-events: none; +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9107dbd795d..e462d16608b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -2,13 +2,14 @@ import { memo, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' -import type { JSONContent } from '@tiptap/core' +import type { Extensions, JSONContent } from '@tiptap/core' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' import { dropPoint } from '@tiptap/pm/transform' import type { Editor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react' import { useRouter } from 'next/navigation' +import { useSession } from '@/lib/auth/auth-client' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' @@ -16,6 +17,7 @@ import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' import { PreviewLoadingFrame } from '../preview-shared' import { useEditableFileContent } from '../use-editable-file-content' +import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' import { findHeadingPos } from './heading-anchors' import { @@ -41,8 +43,10 @@ import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' import './rich-markdown-editor.css' +const PLACEHOLDER = "Write something, or press '/' for commands…" + const EXTENSIONS = createMarkdownEditorExtensions({ - placeholder: "Write something, or press '/' for commands…", + placeholder: PLACEHOLDER, embeds: true, }) @@ -90,6 +94,18 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ previewContextKey, disableTagging, }: RichMarkdownEditorProps) { + const { data: session } = useSession() + const userId = session?.user?.id ?? '' + const userName = session?.user?.name?.trim() || 'Collaborator' + + /** + * Autosave gate for the collaborative path: the child reports `false` while its + * shared document is still syncing/seeding and `true` once it is safe to persist + * the markdown mirror — so an empty or partially-synced doc can never overwrite + * the real file. `true` for non-collaborative files (never gated). + */ + const [collabReady, setCollabReady] = useState(true) + const { content, setDraftContent, @@ -108,6 +124,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ saveRef, discardRef, normalizeBaseline: normalizeMarkdownContent, + canAutosave: collabReady, }) if (isContentLoading) return @@ -128,12 +145,15 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ content={content} isStreaming={isStreamInteractionLocked} canEdit={canEdit} + userId={userId} + userName={userName} autoFocus={autoFocus} streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} disableTagging={disableTagging} onChange={setDraftContent} onSaveShortcut={saveImmediately} + onCollabReadyChange={setCollabReady} /> ) }) @@ -146,6 +166,9 @@ interface LoadedRichMarkdownEditorProps { /** True while agent output is streaming in: the editor renders it read-only and syncs each chunk. */ isStreaming: boolean canEdit: boolean + /** Current user id + display name, for the collaborative caret identity. */ + userId: string + userName: string autoFocus?: boolean /** See {@link RichMarkdownEditorProps.streamIsIncremental}. */ streamIsIncremental?: boolean @@ -153,6 +176,8 @@ interface LoadedRichMarkdownEditorProps { disableTagging?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise + /** Reports whether the collaborative document is synced+seeded (autosave gate). */ + onCollabReadyChange: (ready: boolean) => void } interface SettledContent { @@ -172,12 +197,15 @@ export function LoadedRichMarkdownEditor({ content, isStreaming, canEdit, + userId, + userName, autoFocus, streamIsIncremental, disableStreamingAutoScroll, disableTagging, onChange, onSaveShortcut, + onCollabReadyChange, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -189,9 +217,40 @@ export function LoadedRichMarkdownEditor({ } const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false) - /** Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. */ + /** + * Collaboration is decided ONCE at mount (TipTap fixes the extension set at + * editor creation, so it cannot turn on later): only an editable, round-trip-safe, + * non-streaming workspace document with a known user. Latched via a ref because + * `settledRef`/`streamingAtMountRef` are available synchronously at mount. + */ + const collaborationEnabledRef = useRef(null) + if ( + collaborationEnabledRef.current === null && + (streamingAtMountRef.current || settledRef.current !== null) + ) { + collaborationEnabledRef.current = + canEdit && + !streamingAtMountRef.current && + (settledRef.current?.verdict ?? false) && + Boolean(userId) && + (file.storageContext ?? 'workspace') === 'workspace' + } + const collaboration = useFileDocCollaboration({ + fileId: file.id, + userId, + userName, + enabled: collaborationEnabledRef.current ?? false, + }) + + /** + * Initial editor content. When collaborating, the Y.Doc is the source of truth — + * start empty and let the seed handshake fill it (below); otherwise seed from the + * parsed markdown (chunked parse is linear vs the editor's ~O(n²) whole-body parse). + */ const [initialContent] = useState(() => - streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) + streamingAtMountRef.current || collaborationEnabledRef.current + ? '' + : parseMarkdownToDoc(splitFrontmatter(content).body) ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -289,8 +348,27 @@ export function LoadedRichMarkdownEditor({ } } + /** + * Extensions: the shared module set for the local path, or a per-instance set + * carrying this document's Collaboration + CollaborationCaret. Built once (collab + * is decided at mount), since `useEditor` fixes the extension set at creation. + */ + const [extensions] = useState(() => + collaboration + ? createMarkdownEditorExtensions({ + placeholder: PLACEHOLDER, + embeds: true, + collaboration: { + doc: collaboration.doc, + awareness: collaboration.awareness, + user: collaboration.user, + }, + }) + : EXTENSIONS + ) + const editor = useEditor({ - extensions: EXTENSIONS, + extensions, editable: isEditable, enablePasteRules: false, autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false, @@ -449,6 +527,75 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor + const onCollabReadyChangeRef = useRef(onCollabReadyChange) + onCollabReadyChangeRef.current = onCollabReadyChange + const seedRequestedRef = useRef(false) + // The markdown body to seed the shared doc from — the loaded file content, + // captured before any edit (the editor stays empty + gated until seeded). + const seedBodyRef = useRef(splitFrontmatter(content).body) + seedBodyRef.current = splitFrontmatter(content).body + + /** + * Seed the shared document from the file's markdown when this client is elected + * (SEED_REQUEST) and the initial sync has completed — writing the content and the + * `initialContentLoaded` flag in ONE Yjs transaction, so a re-election can never + * duplicate content (the relay's seed contract). + */ + useEffect(() => { + const provider = collaboration?.provider + const doc = collaboration?.doc + if (!provider || !doc || !editor) return + const config = doc.getMap('config') + const seedIfElected = () => { + if (!seedRequestedRef.current || !provider.synced) return + if (config.get('initialContentLoaded') === true) return + doc.transact(() => { + editor.commands.setContent(parseMarkdownToDoc(seedBodyRef.current), { + contentType: 'json', + emitUpdate: false, + }) + config.set('initialContentLoaded', true) + }) + } + const onSeedRequest = () => { + seedRequestedRef.current = true + seedIfElected() + } + provider.on('seed-request', onSeedRequest) + provider.on('synced', seedIfElected) + seedIfElected() + return () => { + provider.off('seed-request', onSeedRequest) + provider.off('synced', seedIfElected) + } + }, [collaboration, editor]) + + /** + * Gate the parent's autosave: report ready only once the shared document is both + * synced and seeded, so an empty or still-syncing doc can never overwrite the real + * file's markdown mirror. Non-collaborative documents are never gated. + */ + useEffect(() => { + if (!collaboration) { + onCollabReadyChangeRef.current(true) + return + } + const { provider, doc } = collaboration + const config = doc.getMap('config') + const report = () => + onCollabReadyChangeRef.current( + Boolean(provider?.synced) && config.get('initialContentLoaded') === true + ) + report() + provider?.on('synced', report) + config.observe(report) + return () => { + provider?.off('synced', report) + config.unobserve(report) + onCollabReadyChangeRef.current(true) + } + }, [collaboration]) + /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is * shared across instances). Reads only refs, so the handler stays stable across the editor's life. @@ -473,6 +620,9 @@ export function LoadedRichMarkdownEditor({ const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return + // When collaborating, the Y.Doc is the source of truth — content flows in via + // the seed handshake + live sync, never through this manual reconcile loop. + if (collaborationEnabledRef.current) return const syncEditorBody = (body: string) => { if (body === lastSyncedBodyRef.current) return lastSyncedBodyRef.current = body diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index cb6defd8d95..f74e38660c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -66,6 +66,13 @@ interface UseEditableFileContentOptions { * the at-rest baseline, never while an agent stream is in flight. Stable reference required. */ normalizeBaseline?: (raw: string) => string + /** + * Extra gate on autosave (and draft persistence). When `false`, saving is + * suppressed even when otherwise eligible — the collaborative editor uses it to + * hold saves until the shared document is synced AND seeded, so an empty or + * partially-synced doc can never overwrite the real file. Defaults to `true`. + */ + canAutosave?: boolean } interface EditableFileContent { @@ -141,6 +148,7 @@ export function useEditableFileContent({ saveRef, discardRef, normalizeBaseline, + canAutosave = true, }: UseEditableFileContentOptions): EditableFileContent { const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) @@ -239,7 +247,7 @@ export function useEditableFileContent({ [workspaceId, file.id, markSavedContent] ) - const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked + const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked && canAutosave const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({ content, From dcf989835e89d819479b33f70b845eb1ad15e381 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:17:39 -0700 Subject: [PATCH 03/13] fix(files): harden collab editor lifecycle (review + audit round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review + an independent regression audit found three real lifecycle gaps plus a session race; consolidated fix: - Seed-request race: the transient seed-request event could fire before the editor subscribes, leaving the doc empty forever. The provider now LATCHES it as `shouldSeed`, and the editor checks the flag on subscription. - Fatal join, no fallback: a non-retryable join error (access denied, client-id conflict) left the collab-latched editor empty with autosave disabled. It now DEGRADES to local editing — seeds the loaded content and ungates autosave, so the file stays editable (edits persist via the markdown mirror; only live sync is off). - Stream-after-collab: an agent stream starting on a collab-open file was skipped entirely (stale display). The reconcile loop now runs while streaming even in collab mode (guard is `collaborationEnabled && !isStreaming`). - Session race (regression audit F1): on a cold load useSession is briefly undefined, latching collaboration OFF permanently → both cold-loaders solo-save (last-write-wins). The editor now waits for the session before deciding. Also folds in the /simplify wins: collaborationEnabled is a useState-init const (the ref latch was provably always-resolved on render 1); the seed body is held by pointer so it parses once at seed time; seeding + gating + degrade are one lifecycle effect; the doc is only allocated when collaboration is enabled (F2); and the selection tint reuses the now-exported withAlpha instead of reimplementing it. apps/sim typecheck clean; editor suite (442) + provider tests green. --- .../collaboration/file-doc-provider.test.ts | 6 +- .../collaboration/file-doc-provider.ts | 7 + .../use-file-doc-collaboration.ts | 18 +- .../rich-markdown-editor/editor-extensions.ts | 5 +- .../rich-markdown-editor.tsx | 154 ++++++++++-------- apps/sim/lib/workspaces/colors.ts | 2 +- 6 files changed, 111 insertions(+), 81 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index a1af842f73b..978a29cc4f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -83,14 +83,17 @@ describe('FileDocProvider', () => { expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) }) - it('emits seed-request when the server elects it to seed', () => { + it('emits seed-request and latches shouldSeed when the server elects it', () => { const { provider, fire } = createProvider(true) const seed = vi.fn() provider.on('seed-request', seed) + expect(provider.shouldSeed).toBe(false) fire(FILE_DOC_EVENTS.SEED_REQUEST, { fileId: 'file-1' }) expect(seed).toHaveBeenCalledTimes(1) + // Latched so a consumer subscribing after the event can still detect election. + expect(provider.shouldSeed).toBe(true) }) it('ignores acks and seed requests for a different file', () => { @@ -103,6 +106,7 @@ describe('FileDocProvider', () => { fire(FILE_DOC_EVENTS.SEED_REQUEST, { fileId: 'other-file' }) expect(seed).not.toHaveBeenCalled() + expect(provider.shouldSeed).toBe(false) expect(emittedMessages(emit)).toHaveLength(0) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index afc281e1c91..5143cab56d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -43,6 +43,12 @@ interface FileDocProviderEvents { */ export class FileDocProvider extends ObservableV2 { synced = false + /** + * Latched `true` when the server elects this client to seed the document. The + * `seed-request` event is transient and can fire before a consumer subscribes, + * so consumers read this flag on subscription rather than relying on the event. + */ + shouldSeed = false private disposed = false /** Set on a non-retryable join rejection (e.g. lost write access) so the @@ -102,6 +108,7 @@ export class FileDocProvider extends ObservableV2 { private handleSeedRequest = (data: SeedRequestPayload) => { if (data.fileId !== this.fileId) return + this.shouldSeed = true this.emit('seed-request', []) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts index aa76665a213..9fe069b56d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -51,12 +51,16 @@ export function useFileDocCollaboration({ }: UseFileDocCollaborationParams): FileDocCollaboration | null { const { socket } = useSocket() - // The Y.Doc + Awareness are the editor's authoritative binding — lazily created - // once and stable for the hook's life (see sim-react-performance: lazy-init ref). + // The Y.Doc + Awareness are the editor's authoritative binding — created once + // and stable for the hook's life (see sim-react-performance: lazy-init ref). + // Only allocated when collaboration is enabled, so read-only / streaming / + // round-trip-unsafe views never build a Yjs document they won't use. const docRef = useRef(null) - docRef.current ??= new Y.Doc() const awarenessRef = useRef(null) - awarenessRef.current ??= new Awareness(docRef.current) + if (enabled && docRef.current === null) { + docRef.current = new Y.Doc() + awarenessRef.current = new Awareness(docRef.current) + } const [provider, setProvider] = useState(null) @@ -64,11 +68,9 @@ export function useFileDocCollaboration({ // AFTER the provider effect's cleanup (cleanups run in reverse declaration // order) — the provider detaches from the doc/awareness before they're destroyed. useEffect(() => { - const doc = docRef.current - const awareness = awarenessRef.current return () => { - awareness?.destroy() - doc?.destroy() + awarenessRef.current?.destroy() + docRef.current?.destroy() } }, []) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index b2e9aaacba2..2dfb05f3821 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -4,7 +4,7 @@ import CollaborationCaret from '@tiptap/extension-collaboration-caret' import Placeholder from '@tiptap/extension-placeholder' import type { Awareness } from 'y-protocols/awareness' import type * as Y from 'yjs' -import { hexToRgb } from '@/lib/colors' +import { withAlpha } from '@/lib/workspaces/colors' import { BlockMover } from './block-mover' import { CodeBlockWithLanguage } from './code-block' import { CodeBlockHighlight } from './code-highlight' @@ -72,10 +72,9 @@ export function createMarkdownEditorExtensions({ user: collaboration.user, selectionRender: (user) => { const hex = typeof user.color === 'string' ? user.color : '#000000' - const { r, g, b } = hexToRgb(hex) return { class: 'collaboration-carets__selection', - style: `background-color: rgba(${r}, ${g}, ${b}, 0.2);`, + style: `background-color: ${withAlpha(hex, 0.2)};`, } }, }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index e462d16608b..69930973df6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' +import type { JoinFileDocError } from '@sim/realtime-protocol/file-doc' import type { Extensions, JSONContent } from '@tiptap/core' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' @@ -94,7 +95,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ previewContextKey, disableTagging, }: RichMarkdownEditorProps) { - const { data: session } = useSession() + const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' const userName = session?.user?.name?.trim() || 'Collaborator' @@ -127,7 +128,11 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ canAutosave: collabReady, }) - if (isContentLoading) return + // Wait for the session too: the child decides collaboration ONCE at mount from + // `userId`, so mounting before the session resolves would latch collaboration off + // for a cold-loaded file (both users would then solo-save, last-write-wins). + if (isContentLoading || isSessionPending) + return if (hasContentError) { return ( @@ -220,26 +225,23 @@ export function LoadedRichMarkdownEditor({ /** * Collaboration is decided ONCE at mount (TipTap fixes the extension set at * editor creation, so it cannot turn on later): only an editable, round-trip-safe, - * non-streaming workspace document with a known user. Latched via a ref because - * `settledRef`/`streamingAtMountRef` are available synchronously at mount. + * non-streaming workspace document with a known user. All inputs are available + * synchronously at mount (`settledRef` is set just above), so this is decided + * once via `useState`-init and never changes. */ - const collaborationEnabledRef = useRef(null) - if ( - collaborationEnabledRef.current === null && - (streamingAtMountRef.current || settledRef.current !== null) - ) { - collaborationEnabledRef.current = + const [collaborationEnabled] = useState( + () => canEdit && !streamingAtMountRef.current && (settledRef.current?.verdict ?? false) && Boolean(userId) && (file.storageContext ?? 'workspace') === 'workspace' - } + ) const collaboration = useFileDocCollaboration({ fileId: file.id, userId, userName, - enabled: collaborationEnabledRef.current ?? false, + enabled: collaborationEnabled, }) /** @@ -248,7 +250,7 @@ export function LoadedRichMarkdownEditor({ * parsed markdown (chunked parse is linear vs the editor's ~O(n²) whole-body parse). */ const [initialContent] = useState(() => - streamingAtMountRef.current || collaborationEnabledRef.current + streamingAtMountRef.current || collaborationEnabled ? '' : parseMarkdownToDoc(splitFrontmatter(content).body) ) @@ -527,74 +529,78 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor - const onCollabReadyChangeRef = useRef(onCollabReadyChange) - onCollabReadyChangeRef.current = onCollabReadyChange - const seedRequestedRef = useRef(false) - // The markdown body to seed the shared doc from — the loaded file content, - // captured before any edit (the editor stays empty + gated until seeded). - const seedBodyRef = useRef(splitFrontmatter(content).body) - seedBodyRef.current = splitFrontmatter(content).body + // The loaded markdown to seed the shared doc from, held by pointer so the parse + // runs once at seed time rather than every render. + const seedContentRef = useRef(content) + seedContentRef.current = content /** - * Seed the shared document from the file's markdown when this client is elected - * (SEED_REQUEST) and the initial sync has completed — writing the content and the - * `initialContentLoaded` flag in ONE Yjs transaction, so a re-election can never - * duplicate content (the relay's seed contract). + * The collaborative document lifecycle. In one effect because the three concerns + * are one state machine keyed off the same provider events: + * - **seed** the doc from the loaded markdown when this client is elected and + * synced (content + `initialContentLoaded` flag in ONE Yjs transaction, so a + * re-election can never duplicate content — the relay's exactly-once contract); + * - **gate** the parent's autosave until the doc is synced AND seeded, so an + * empty/still-syncing doc can never overwrite the real file's markdown mirror; + * - **degrade** to local editing on a fatal join (seed the loaded content and + * ungate autosave, so the file stays editable — edits persist through the + * mirror, only live sync is off). + * + * Non-collaborative documents are never gated. `provider.shouldSeed` is latched, + * so a SEED_REQUEST that arrived before this subscription is not missed. */ useEffect(() => { - const provider = collaboration?.provider - const doc = collaboration?.doc - if (!provider || !doc || !editor) return + if (!collaboration) { + onCollabReadyChange(true) + return + } + const { provider, doc } = collaboration + if (!provider || !editor) { + onCollabReadyChange(false) + return + } const config = doc.getMap('config') - const seedIfElected = () => { - if (!seedRequestedRef.current || !provider.synced) return + let degraded = false + + const seedFromLoaded = () => { if (config.get('initialContentLoaded') === true) return doc.transact(() => { - editor.commands.setContent(parseMarkdownToDoc(seedBodyRef.current), { - contentType: 'json', - emitUpdate: false, - }) + editor.commands.setContent( + parseMarkdownToDoc(splitFrontmatter(seedContentRef.current).body), + { contentType: 'json', emitUpdate: false } + ) config.set('initialContentLoaded', true) }) } - const onSeedRequest = () => { - seedRequestedRef.current = true - seedIfElected() + const report = () => + onCollabReadyChange( + degraded || (provider.synced && config.get('initialContentLoaded') === true) + ) + const onProgress = () => { + if (provider.shouldSeed && provider.synced) seedFromLoaded() + report() } - provider.on('seed-request', onSeedRequest) - provider.on('synced', seedIfElected) - seedIfElected() - return () => { - provider.off('seed-request', onSeedRequest) - provider.off('synced', seedIfElected) + const onJoinError = (error: JoinFileDocError) => { + if (error.retryable !== false) return + degraded = true + seedFromLoaded() + report() } - }, [collaboration, editor]) - /** - * Gate the parent's autosave: report ready only once the shared document is both - * synced and seeded, so an empty or still-syncing doc can never overwrite the real - * file's markdown mirror. Non-collaborative documents are never gated. - */ - useEffect(() => { - if (!collaboration) { - onCollabReadyChangeRef.current(true) - return - } - const { provider, doc } = collaboration - const config = doc.getMap('config') - const report = () => - onCollabReadyChangeRef.current( - Boolean(provider?.synced) && config.get('initialContentLoaded') === true - ) - report() - provider?.on('synced', report) + provider.on('seed-request', onProgress) + provider.on('synced', onProgress) + provider.on('join-error', onJoinError) config.observe(report) + onProgress() + return () => { - provider?.off('synced', report) + provider.off('seed-request', onProgress) + provider.off('synced', onProgress) + provider.off('join-error', onJoinError) config.unobserve(report) - onCollabReadyChangeRef.current(true) + onCollabReadyChange(true) } - }, [collaboration]) + }, [collaboration, editor, onCollabReadyChange]) /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is @@ -620,9 +626,13 @@ export function LoadedRichMarkdownEditor({ const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return - // When collaborating, the Y.Doc is the source of truth — content flows in via - // the seed handshake + live sync, never through this manual reconcile loop. - if (collaborationEnabledRef.current) return + // When collaborating, the Y.Doc is the source of truth for at-rest content, so + // skip this manual reconcile loop — EXCEPT while an agent is streaming, where the + // stream (and its settle) must still run so the agent's output is shown and + // reconciled (it flows into the shared doc via `setContent`). Collab and streaming + // are mutually exclusive at mount, so this only matters for a stream that starts + // after a collaborative open. + if (collaborationEnabled && !isStreaming) return const syncEditorBody = (body: string) => { if (body === lastSyncedBodyRef.current) return lastSyncedBodyRef.current = body @@ -698,7 +708,15 @@ export function LoadedRichMarkdownEditor({ } syncEditorBody(splitFrontmatter(content).body) if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict) - }, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll]) + }, [ + editor, + content, + isStreaming, + canEdit, + autoFocus, + disableStreamingAutoScroll, + collaborationEnabled, + ]) useEffect( () => () => { diff --git a/apps/sim/lib/workspaces/colors.ts b/apps/sim/lib/workspaces/colors.ts index 61401b68ad1..079a4b4e4cd 100644 --- a/apps/sim/lib/workspaces/colors.ts +++ b/apps/sim/lib/workspaces/colors.ts @@ -59,7 +59,7 @@ function hashIdentifier(identifier: string | number): number { return 0 } -function withAlpha(hexColor: string, alpha: number): string { +export function withAlpha(hexColor: string, alpha: number): string { if (!HEX_COLOR_REGEX.test(hexColor)) { return hexColor } From 7fcd273d57d8f5c41b34d7b4f1fd9ff4656d3a1d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:23:03 -0700 Subject: [PATCH 04/13] docs(files): convert method-level collab comments to TSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment pass (per the /you-might-not-need-a-comment skill): the four inline // blocks that document a symbol become TSDoc for consistency with their siblings — FileDocProvider join/handleJoinSuccess/handleJoinError and the editor's seedContentRef. The remaining inline comments are genuine line-specific WHY (transaction-origin, awareness-ownership, cleanup ordering, collab-vs-streaming) that can't be expressed as symbol TSDoc; no restatement/noise was found. --- .../collaboration/file-doc-provider.ts | 16 +++++++++++----- .../rich-markdown-editor.tsx | 6 ++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 5143cab56d6..7d4cdc648ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -74,9 +74,9 @@ export class FileDocProvider extends ObservableV2 { if (socket.connected) this.join() } + /** Join the room, binding our client id so the server only accepts awareness we own. */ private join = () => { if (this.fatal) return - // Bind our client id at join so the server only accepts awareness we own. this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, clientId: this.doc.clientID }) } @@ -90,18 +90,24 @@ export class FileDocProvider extends ObservableV2 { this.join() } + /** + * Handle the join ack. Membership is now registered, so it is safe to send — an + * earlier send could be dropped because the server registers the room before this + * ack — so the initial sync + local awareness exchange begins here. + */ private handleJoinSuccess = (data: JoinFileDocSuccess) => { if (data.fileId !== this.fileId) return - // We are now a member of the room, so it is safe to send messages (an earlier - // send could be dropped: the server registers the room before this ack). this.sendSyncStep1() this.sendLocalAwareness() } + /** + * Handle a join rejection. A non-retryable rejection (access denied, invalid) + * won't succeed on retry, so latch {@link fatal} to stop (re)joining and let the + * owner fall back to the non-collaborative view. + */ private handleJoinError = (data: JoinFileDocError) => { if (data.fileId !== this.fileId) return - // A non-retryable rejection (access denied, invalid) won't succeed on retry; - // stop (re)joining and let the owner fall back to the non-collaborative view. if (data.retryable === false) this.fatal = true this.emit('join-error', [data]) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 69930973df6..9d2d2b07932 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -529,8 +529,10 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor - // The loaded markdown to seed the shared doc from, held by pointer so the parse - // runs once at seed time rather than every render. + /** + * The loaded markdown to seed the shared doc from, held by pointer so the parse + * runs once at seed time rather than every render. + */ const seedContentRef = useRef(content) seedContentRef.current = content From 184bdc796340c88192da5d51dc585a16006fa040 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:28:17 -0700 Subject: [PATCH 05/13] fix(files): run post-stream settle in collab mode The previous collab guard (`collaborationEnabled && !isStreaming`) let the stream display run but skipped the SETTLE branch once the stream ended, leaving a collab-open editor read-only after an agent stream. The guard now also allows the effect through while settling (`!wasStreamingRef.current`, true until the settle branch consumes it), so the editor is re-enabled and finalized after a stream; steady-state collab still skips the manual reconcile loop. --- .../rich-markdown-editor/rich-markdown-editor.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9d2d2b07932..af0e30c5d59 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -629,12 +629,13 @@ export function LoadedRichMarkdownEditor({ useEffect(() => { if (!editor) return // When collaborating, the Y.Doc is the source of truth for at-rest content, so - // skip this manual reconcile loop — EXCEPT while an agent is streaming, where the - // stream (and its settle) must still run so the agent's output is shown and - // reconciled (it flows into the shared doc via `setContent`). Collab and streaming - // are mutually exclusive at mount, so this only matters for a stream that starts - // after a collaborative open. - if (collaborationEnabled && !isStreaming) return + // skip this manual reconcile loop in steady state. But an agent stream that starts + // after a collaborative open must still run — both while streaming (`isStreaming`) + // and through its settle (`wasStreamingRef`, true until the settle branch consumes + // it) — so the agent's output is shown, flows into the shared doc via `setContent`, + // and the editor is re-enabled on settle. Collab and streaming are mutually + // exclusive at mount, so this only affects a stream begun after a collaborative open. + if (collaborationEnabled && !isStreaming && !wasStreamingRef.current) return const syncEditorBody = (body: string) => { if (body === lastSyncedBodyRef.current) return lastSyncedBodyRef.current = body From 267503ae78a3afc451746b73b12efd8342b5377e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:37:32 -0700 Subject: [PATCH 06/13] fix(files): gate collab editability + read-only fallback for denied access Round 3 review (Greptile + Cursor): - Editable-before-seed (High): the editor was editable before the shared doc was synced+seeded, so a user could type into an empty doc and lose it to the seed. Added a local collabReady state (synced+seeded, or degraded-writable) that starts false for a collaborative document and gates isEditable + a reactive setEditable. - ACCESS_DENIED can't save: the fatal-degrade made the editor writable, but a permission denial means the content PUT would 403 too. ACCESS_DENIED now degrades to READ-ONLY (content shown, autosave gated); other recoverable fatals stay locally editable. - Draft-recovery race: local collabReady starting false means draft persistence is never enabled for a collaborative document (on top of the existing cancel path). apps/sim typecheck clean; editor suite (442) green. --- .../rich-markdown-editor.tsx | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index af0e30c5d59..6db955a0126 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -220,8 +220,6 @@ export function LoadedRichMarkdownEditor({ if (!streamingAtMountRef.current && settledRef.current === null) { settledRef.current = lockSettled(content) } - const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false) - /** * Collaboration is decided ONCE at mount (TipTap fixes the extension set at * editor creation, so it cannot turn on later): only an editable, round-trip-safe, @@ -237,6 +235,17 @@ export function LoadedRichMarkdownEditor({ Boolean(userId) && (file.storageContext ?? 'workspace') === 'workspace' ) + /** + * Whether the collaborative document is safe to edit + persist: synced and seeded, + * or degraded to writable after a recoverable collaboration failure. Starts + * `false` for a collaborative document — so the editor is read-only and autosave + * gated until the shared content has arrived (a user must not type into an empty, + * unsynced doc, which the seed would then discard) — and `true` for a local one. + */ + const [collabReady, setCollabReady] = useState(!collaborationEnabled) + const isEditable = + canEdit && !isStreaming && (settledRef.current?.verdict ?? false) && collabReady + const collaboration = useFileDocCollaboration({ fileId: file.id, userId, @@ -544,21 +553,28 @@ export function LoadedRichMarkdownEditor({ * re-election can never duplicate content — the relay's exactly-once contract); * - **gate** the parent's autosave until the doc is synced AND seeded, so an * empty/still-syncing doc can never overwrite the real file's markdown mirror; - * - **degrade** to local editing on a fatal join (seed the loaded content and - * ungate autosave, so the file stays editable — edits persist through the - * mirror, only live sync is off). + * - **degrade** on a fatal join: seed the loaded content so it is shown, and — for + * a NON-permission failure (the user can still save) — mark it writable so the + * file stays editable locally (edits persist through the mirror, only live sync + * is off). A permission denial stays read-only (the save would 403 too). * - * Non-collaborative documents are never gated. `provider.shouldSeed` is latched, - * so a SEED_REQUEST that arrived before this subscription is not missed. + * `ready` (synced+seeded, or degraded-writable) gates BOTH the editor's editability + * (a user must never type into an empty/unsynced doc) and the parent's autosave. + * Non-collaborative documents are never gated. `provider.shouldSeed` is latched, so + * a SEED_REQUEST that arrived before this subscription is not missed. */ useEffect(() => { + const setReady = (ready: boolean) => { + setCollabReady(ready) + onCollabReadyChange(ready) + } if (!collaboration) { - onCollabReadyChange(true) + setReady(true) return } const { provider, doc } = collaboration if (!provider || !editor) { - onCollabReadyChange(false) + setReady(false) return } const config = doc.getMap('config') @@ -575,17 +591,17 @@ export function LoadedRichMarkdownEditor({ }) } const report = () => - onCollabReadyChange( - degraded || (provider.synced && config.get('initialContentLoaded') === true) - ) + setReady(degraded || (provider.synced && config.get('initialContentLoaded') === true)) const onProgress = () => { if (provider.shouldSeed && provider.synced) seedFromLoaded() report() } const onJoinError = (error: JoinFileDocError) => { if (error.retryable !== false) return - degraded = true + // Show the loaded content, but only mark it writable when the failure isn't a + // permission denial — a denied user can't save either, so it stays read-only. seedFromLoaded() + if (error.code !== 'ACCESS_DENIED') degraded = true report() } @@ -602,7 +618,18 @@ export function LoadedRichMarkdownEditor({ config.unobserve(report) onCollabReadyChange(true) } - }, [collaboration, editor, onCollabReadyChange]) + }, [collaboration, editor, onCollabReadyChange, setCollabReady]) + + /** + * Apply editability reactively for the collaborative steady state: `useEditor`'s + * `editable` is only the initial value, and the streaming/settle effect (which owns + * editability while and just after a stream) is skipped otherwise — so re-apply + * here when collaboration readiness flips the editor from read-only to editable. + */ + useEffect(() => { + if (!editor || !collaborationEnabled || isStreaming) return + if (editor.isEditable !== isEditable) editor.setEditable(isEditable) + }, [editor, collaborationEnabled, isStreaming, isEditable]) /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is From 80ff02cdb4b33f8881d63249cae8f1312ae6b0fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:43:17 -0700 Subject: [PATCH 07/13] fix(files): latch fatal join-error + gate stream settle on collab readiness Round 4 review (Greptile): - Fatal join fallback lost: like the seed race, a non-retryable join-error firing before the editor subscribed was missed. The provider now LATCHES it as `joinError`, and the editor checks it on subscription (alongside shouldSeed). - Stream settle bypassed the auth gate: the settle branch's setEditable wasn't gated on collab readiness, so a stream on an ACCESS_DENIED (read-only) file re-enabled editing. Both settle setEditable calls now include `collabReady` (no-op for non-collab, where it's always true). apps/sim typecheck clean; editor suite (442) green; provider joinError latch tested. --- .../collaboration/file-doc-provider.test.ts | 11 +++++++---- .../collaboration/file-doc-provider.ts | 11 ++++++++++- .../rich-markdown-editor/rich-markdown-editor.tsx | 8 ++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 978a29cc4f5..97fd5e13996 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -183,19 +183,22 @@ describe('FileDocProvider', () => { expect(emittedMessages(emit).some((m) => m[0] === FILE_DOC_MESSAGE_TYPE.AWARENESS)).toBe(false) }) - it('stops attempting to join after a non-retryable error', () => { - const { emit, fire } = createProvider(true) + it('stops attempting to join and latches joinError after a non-retryable error', () => { + const { provider, emit, fire } = createProvider(true) emit.mockClear() - fire(FILE_DOC_EVENTS.JOIN_ERROR, { + const error = { fileId: 'file-1', error: 'Access denied', code: 'ACCESS_DENIED', retryable: false, - }) + } + fire(FILE_DOC_EVENTS.JOIN_ERROR, error) fire('connect') expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) + // Latched so a consumer subscribing after the event can still detect the failure. + expect(provider.joinError).toEqual(error) }) it('still rejoins on reconnect after a retryable error', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 7d4cdc648ff..291634b6872 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -49,6 +49,12 @@ export class FileDocProvider extends ObservableV2 { * so consumers read this flag on subscription rather than relying on the event. */ shouldSeed = false + /** + * The latched non-retryable join rejection, or `null`. Like {@link shouldSeed}, + * the `join-error` event is transient and can fire before a consumer subscribes, + * so consumers read this on subscription to detect a fatal failure they missed. + */ + joinError: JoinFileDocError | null = null private disposed = false /** Set on a non-retryable join rejection (e.g. lost write access) so the @@ -108,7 +114,10 @@ export class FileDocProvider extends ObservableV2 { */ private handleJoinError = (data: JoinFileDocError) => { if (data.fileId !== this.fileId) return - if (data.retryable === false) this.fatal = true + if (data.retryable === false) { + this.fatal = true + this.joinError = data + } this.emit('join-error', [data]) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 6db955a0126..517ef6d2b5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -609,7 +609,10 @@ export function LoadedRichMarkdownEditor({ provider.on('synced', onProgress) provider.on('join-error', onJoinError) config.observe(report) + // Catch a seed-request / fatal join-error that fired before this subscription + // (both are latched on the provider). onProgress() + if (provider.joinError) onJoinError(provider.joinError) return () => { provider.off('seed-request', onProgress) @@ -732,12 +735,12 @@ export function LoadedRichMarkdownEditor({ // entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the // user is doing outside the editor. editor.commands.setTextSelection(editor.state.doc.content.size) - editor.setEditable(canEdit && settledRef.current.verdict) + editor.setEditable(canEdit && settledRef.current.verdict && collabReady) if (isInitialSettle && autoFocus) editor.commands.focus('end') return } syncEditorBody(splitFrontmatter(content).body) - if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict) + if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict && collabReady) }, [ editor, content, @@ -746,6 +749,7 @@ export function LoadedRichMarkdownEditor({ autoFocus, disableStreamingAutoScroll, collaborationEnabled, + collabReady, ]) useEffect( From dc324ba5a32b4bfdb941c2fe79fe355465c8bed0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 17:48:37 -0700 Subject: [PATCH 08/13] fix(files): all fatal collab joins fall back to read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 review (Greptile): AUTHENTICATION_REQUIRED is the same class as ACCESS_DENIED — the durable save would fail too — but the previous code-specific check only excluded ACCESS_DENIED, so an auth failure degraded to editable-but- unsavable (silently dropping edits). Every non-retryable join failure (auth, access denied, not found, client-id conflict) now uniformly falls back to a read-only view of the loaded content: seed it so it's shown, keep the editor read-only + autosave gated. Removes the fragile per-code list and the 'degraded' writable path entirely. apps/sim typecheck clean; editor suite (442) green. --- .../rich-markdown-editor.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 517ef6d2b5d..e47fbe0e9ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -553,15 +553,16 @@ export function LoadedRichMarkdownEditor({ * re-election can never duplicate content — the relay's exactly-once contract); * - **gate** the parent's autosave until the doc is synced AND seeded, so an * empty/still-syncing doc can never overwrite the real file's markdown mirror; - * - **degrade** on a fatal join: seed the loaded content so it is shown, and — for - * a NON-permission failure (the user can still save) — mark it writable so the - * file stays editable locally (edits persist through the mirror, only live sync - * is off). A permission denial stays read-only (the save would 403 too). + * - **fall back** on a fatal join: seed the loaded content so it is SHOWN, but + * leave the editor read-only + gated. Every non-retryable failure (auth, access + * denied, not found, client-id conflict) either can't save or is moot, so the + * safe fallback is a read-only view of the content rather than editable-but- + * unsavable — which would silently drop the user's edits. * - * `ready` (synced+seeded, or degraded-writable) gates BOTH the editor's editability - * (a user must never type into an empty/unsynced doc) and the parent's autosave. - * Non-collaborative documents are never gated. `provider.shouldSeed` is latched, so - * a SEED_REQUEST that arrived before this subscription is not missed. + * `ready` (synced+seeded) gates BOTH the editor's editability (a user must never + * type into an empty/unsynced doc) and the parent's autosave. Non-collaborative + * documents are never gated. `provider.shouldSeed` / `provider.joinError` are + * latched, so events that fired before this subscription are not missed. */ useEffect(() => { const setReady = (ready: boolean) => { @@ -578,7 +579,6 @@ export function LoadedRichMarkdownEditor({ return } const config = doc.getMap('config') - let degraded = false const seedFromLoaded = () => { if (config.get('initialContentLoaded') === true) return @@ -590,19 +590,15 @@ export function LoadedRichMarkdownEditor({ config.set('initialContentLoaded', true) }) } - const report = () => - setReady(degraded || (provider.synced && config.get('initialContentLoaded') === true)) + const report = () => setReady(provider.synced && config.get('initialContentLoaded') === true) const onProgress = () => { if (provider.shouldSeed && provider.synced) seedFromLoaded() report() } const onJoinError = (error: JoinFileDocError) => { - if (error.retryable !== false) return - // Show the loaded content, but only mark it writable when the failure isn't a - // permission denial — a denied user can't save either, so it stays read-only. - seedFromLoaded() - if (error.code !== 'ACCESS_DENIED') degraded = true - report() + // Show the loaded content, but keep it read-only + gated: a fatal join means + // the durable save would fail too, so editable-but-unsavable would lose edits. + if (error.retryable === false) seedFromLoaded() } provider.on('seed-request', onProgress) From 84ed86300148ae019bfd1185186d5fc669aac37a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 18:14:42 -0700 Subject: [PATCH 09/13] =?UTF-8?q?chore(files):=20cleanup=20pass=20?= =?UTF-8?q?=E2=80=94=20label=20contrast,=20dead-code,=20comment=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix collaborative caret name-label text: white was unreadable on the light-pastel user colors; use fixed dark (#1a1a1a) against the theme-independent per-user background, and widen the caret hover target via a ::before so the name is discoverable without the label ever intercepting clicks. - Remove pre-existing dead exports from lib/workspaces/colors.ts (getPresenceColors, createUserColorMap, APP_COLORS, PresenceColorPalette, and the now-orphaned buildGradient) — zero consumers repo-wide. - Drop the stale 'degraded to writable' clause from the collabReady doc (that path was removed) and tighten/strip comments that restated adjacent TSDoc. --- .../collaboration/file-doc-provider.ts | 14 ++-- .../rich-markdown-editor/editor-extensions.ts | 7 +- .../rich-markdown-editor.css | 12 +++- .../rich-markdown-editor.tsx | 23 +++---- apps/sim/lib/workspaces/colors.ts | 68 ------------------- 5 files changed, 28 insertions(+), 96 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 291634b6872..5472ab31668 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -97,9 +97,8 @@ export class FileDocProvider extends ObservableV2 { } /** - * Handle the join ack. Membership is now registered, so it is safe to send — an - * earlier send could be dropped because the server registers the room before this - * ack — so the initial sync + local awareness exchange begins here. + * Handle the join ack. The server registers the room before acking, so an earlier + * send could be dropped — the initial sync + local awareness exchange begins here. */ private handleJoinSuccess = (data: JoinFileDocSuccess) => { if (data.fileId !== this.fileId) return @@ -144,7 +143,6 @@ export class FileDocProvider extends ObservableV2 { if (encoding.length(encoder) > 1) { this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) } - // The first sync step 2 marks the document fully synced with the server. if (syncType === syncProtocol.messageYjsSyncStep2 && !this.synced) this.setSynced(true) break } @@ -215,9 +213,9 @@ export class FileDocProvider extends ObservableV2 { } /** - * Tear down the provider: leave the room, clear our awareness (so our caret - * disappears for everyone else), and detach all listeners. The document and - * awareness objects are the caller's and are left intact. + * Tear down the provider: leave the room, clear our awareness (so peers drop our + * caret immediately rather than after the server's 30s timeout), and detach all + * listeners. The document and awareness objects are the caller's and are left intact. */ destroy() { if (this.disposed) { @@ -226,8 +224,6 @@ export class FileDocProvider extends ObservableV2 { } this.disposed = true - // Clear our local awareness so peers drop our caret immediately, rather than - // waiting for the server's 30s awareness timeout. awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy') this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 2dfb05f3821..12190e62718 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -63,10 +63,9 @@ export function createMarkdownEditorExtensions({ ...(collaboration ? [ Collaboration.configure({ document: collaboration.doc }), - // CollaborationCaret reads only `provider.awareness`; the awareness is - // created synchronously and relayed by the socket provider once connected. - // The default caret + name label already color from `user.color`; only the - // selection is overridden to a lighter translucent tint of the same color. + // CollaborationCaret reads only `provider.awareness` (created synchronously, + // relayed by the socket provider once connected). The default caret + label + // color from `user.color`; only the selection tint needs an explicit override. CollaborationCaret.configure({ provider: { awareness: collaboration.awareness }, user: collaboration.user, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 3c34ef01ea8..2ec9890d8be 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -483,13 +483,23 @@ font-weight: 600; line-height: 1.2; white-space: nowrap; - color: #fff; + /* Fixed dark text: the label background is always a light user color (assigned + * inline, theme-independent), so a theme token would go unreadable in one mode. */ + color: #1a1a1a; user-select: none; pointer-events: none; opacity: 0; transition: opacity 0.12s ease; } +/* Widen the caret's hover target beyond its 1px width so the name label is easy to + * reveal; the label itself stays pointer-events:none and never intercepts clicks. */ +.rich-markdown-prose .collaboration-carets__caret::before { + content: ""; + position: absolute; + inset: -0.1em -2px; +} + .rich-markdown-prose .collaboration-carets__caret:hover .collaboration-carets__label { opacity: 1; } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index e47fbe0e9ad..5c7749a7451 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -221,11 +221,11 @@ export function LoadedRichMarkdownEditor({ settledRef.current = lockSettled(content) } /** - * Collaboration is decided ONCE at mount (TipTap fixes the extension set at - * editor creation, so it cannot turn on later): only an editable, round-trip-safe, - * non-streaming workspace document with a known user. All inputs are available - * synchronously at mount (`settledRef` is set just above), so this is decided - * once via `useState`-init and never changes. + * Collaboration is decided once at mount from synchronously-available inputs + * (`settledRef` is set just above) via `useState`-init, and never changes — TipTap + * fixes the extension set at editor creation, so it cannot turn on later. Enabled + * only for an editable, round-trip-safe, non-streaming workspace document with a + * known user. */ const [collaborationEnabled] = useState( () => @@ -236,11 +236,10 @@ export function LoadedRichMarkdownEditor({ (file.storageContext ?? 'workspace') === 'workspace' ) /** - * Whether the collaborative document is safe to edit + persist: synced and seeded, - * or degraded to writable after a recoverable collaboration failure. Starts - * `false` for a collaborative document — so the editor is read-only and autosave - * gated until the shared content has arrived (a user must not type into an empty, - * unsynced doc, which the seed would then discard) — and `true` for a local one. + * Whether the collaborative document is safe to edit + persist: synced and seeded. + * Starts `false` for a collaborative document — so the editor is read-only and + * autosave gated until the shared content has arrived (a user must not type into an + * empty, unsynced doc, which the seed would then discard) — and `true` for a local one. */ const [collabReady, setCollabReady] = useState(!collaborationEnabled) const isEditable = @@ -596,8 +595,6 @@ export function LoadedRichMarkdownEditor({ report() } const onJoinError = (error: JoinFileDocError) => { - // Show the loaded content, but keep it read-only + gated: a fatal join means - // the durable save would fail too, so editable-but-unsavable would lose edits. if (error.retryable === false) seedFromLoaded() } @@ -605,8 +602,6 @@ export function LoadedRichMarkdownEditor({ provider.on('synced', onProgress) provider.on('join-error', onJoinError) config.observe(report) - // Catch a seed-request / fatal join-error that fired before this subscription - // (both are latched on the provider). onProgress() if (provider.joinError) onJoinError(provider.joinError) diff --git a/apps/sim/lib/workspaces/colors.ts b/apps/sim/lib/workspaces/colors.ts index 079a4b4e4cd..87b1fa34be8 100644 --- a/apps/sim/lib/workspaces/colors.ts +++ b/apps/sim/lib/workspaces/colors.ts @@ -17,15 +17,6 @@ export function getRandomWorkspaceColor(): string { return randomItem(WORKSPACE_COLORS) } -const APP_COLORS = [ - { from: '#4F46E5', to: '#7C3AED' }, // indigo to purple - { from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia - { from: '#EC4899', to: '#F97316' }, // pink to orange - { from: '#14B8A6', to: '#10B981' }, // teal to emerald - { from: '#6366F1', to: '#8B5CF6' }, // indigo to violet - { from: '#F59E0B', to: '#F97316' }, // amber to orange -] - /** * User color palette matching terminal.tsx RUN_ID_COLORS * These colors are used consistently across cursors, avatars, and terminal run IDs @@ -39,12 +30,6 @@ export const USER_COLORS = [ '#FCD34D', // Yellow ] as const -interface PresenceColorPalette { - gradient: string - accentColor: string - baseColor: string -} - const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/ function hashIdentifier(identifier: string | number): number { @@ -68,39 +53,6 @@ export function withAlpha(hexColor: string, alpha: number): string { return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})` } -function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string { - const rotation = (rotationSeed * 25) % 360 - return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})` -} - -export function getPresenceColors( - identifier: string | number, - explicitColor?: string -): PresenceColorPalette { - const paletteIndex = hashIdentifier(identifier) - - if (explicitColor) { - const normalizedColor = explicitColor.trim() - const lighterShade = HEX_COLOR_REGEX.test(normalizedColor) - ? withAlpha(normalizedColor, 0.85) - : normalizedColor - - return { - gradient: buildGradient(lighterShade, normalizedColor, paletteIndex), - accentColor: normalizedColor, - baseColor: lighterShade, - } - } - - const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length] - - return { - gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex), - accentColor: colorPair.to, - baseColor: colorPair.from, - } -} - /** * Gets a consistent color for a user based on their ID. * The same user will always get the same color across cursors, avatars, and terminal. @@ -112,23 +64,3 @@ export function getUserColor(userId: string): string { const hash = hashIdentifier(userId) return USER_COLORS[hash % USER_COLORS.length] } - -/** - * Creates a stable mapping of user IDs to color indices for a list of users. - * Useful when you need to maintain consistent color assignments across renders. - * - * @param userIds - Array of user IDs to map - * @returns Map of user ID to color index - */ -export function createUserColorMap(userIds: string[]): Map { - const colorMap = new Map() - let colorIndex = 0 - - for (const userId of userIds) { - if (!colorMap.has(userId)) { - colorMap.set(userId, colorIndex++) - } - } - - return colorMap -} From 26746a280aa96fe5439dbf9af1bb2b35405427d7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 18:31:24 -0700 Subject: [PATCH 10/13] fix(files): make collab and agent-streaming mutually exclusive + realtime-outage fallback Review round (Greptile P1 + 2 Cursor High): - Stream reconcile loop is now fully inert in collaborative mode. Its setContent syncs a full-document replace into the shared Y.Doc (the ySyncPlugin writes it regardless of emitUpdate:false), which would wipe peers' concurrent edits and feed the settle path a paced, incomplete prefix. Collaboration and agent-streaming are mutually exclusive: collab wins and owns the doc; editability is held read-only for the stream's duration by the reactive effect (which now owns the whole collab lifecycle, not just the non-streaming steady state). - Realtime-outage fallback: if the provider never attaches (socket down), a grace timer seeds the loaded content read-only so the user reads their file instead of a blank editor, instead of staying blank + unsavable indefinitely. A reconnect re-runs the effect onto the normal server-seeded sync path. --- .../rich-markdown-editor.tsx | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 5c7749a7451..fff3ae0d5f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -54,6 +54,13 @@ const EXTENSIONS = createMarkdownEditorExtensions({ /** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */ const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 const STREAM_REPARSE_THROTTLE_MS = 120 +/** + * How long a collaborative editor waits for the realtime provider before falling back + * to a read-only render of the loaded content. Long enough that a normally-connecting + * socket always attaches first (so this only trips on a genuine realtime outage), and + * matched to the server's seed election deadline. + */ +const COLLAB_ESTABLISH_GRACE_MS = 10_000 interface RichMarkdownEditorProps { file: WorkspaceFileRecord @@ -573,7 +580,7 @@ export function LoadedRichMarkdownEditor({ return } const { provider, doc } = collaboration - if (!provider || !editor) { + if (!editor) { setReady(false) return } @@ -589,6 +596,17 @@ export function LoadedRichMarkdownEditor({ config.set('initialContentLoaded', true) }) } + + if (!provider) { + // Realtime is not yet available (socket still connecting, or a full outage). Stay + // read-only + gated. If the provider never arrives, a grace timer seeds the loaded + // content so the user reads their file instead of a blank editor; a reconnect + // re-runs this effect with a live provider onto the normal server-seeded sync path. + setReady(false) + const graceTimer = setTimeout(seedFromLoaded, COLLAB_ESTABLISH_GRACE_MS) + return () => clearTimeout(graceTimer) + } + const report = () => setReady(provider.synced && config.get('initialContentLoaded') === true) const onProgress = () => { if (provider.shouldSeed && provider.synced) seedFromLoaded() @@ -615,15 +633,17 @@ export function LoadedRichMarkdownEditor({ }, [collaboration, editor, onCollabReadyChange, setCollabReady]) /** - * Apply editability reactively for the collaborative steady state: `useEditor`'s - * `editable` is only the initial value, and the streaming/settle effect (which owns - * editability while and just after a stream) is skipped otherwise — so re-apply - * here when collaboration readiness flips the editor from read-only to editable. + * Owns editability for the entire collaborative lifecycle: `useEditor`'s `editable` + * is only the initial value, and the streaming/settle effect stays inert in collab + * mode (they are mutually exclusive) — so re-apply here whenever `isEditable` flips, + * whether from collaboration readiness (synced + seeded) or a stream toggling + * `isStreaming`. `isEditable` already folds in `!isStreaming`, holding read-only for + * the duration of any agent stream over a collaborative document. */ useEffect(() => { - if (!editor || !collaborationEnabled || isStreaming) return + if (!editor || !collaborationEnabled) return if (editor.isEditable !== isEditable) editor.setEditable(isEditable) - }, [editor, collaborationEnabled, isStreaming, isEditable]) + }, [editor, collaborationEnabled, isEditable]) /** * Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is @@ -649,14 +669,14 @@ export function LoadedRichMarkdownEditor({ const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return - // When collaborating, the Y.Doc is the source of truth for at-rest content, so - // skip this manual reconcile loop in steady state. But an agent stream that starts - // after a collaborative open must still run — both while streaming (`isStreaming`) - // and through its settle (`wasStreamingRef`, true until the settle branch consumes - // it) — so the agent's output is shown, flows into the shared doc via `setContent`, - // and the editor is re-enabled on settle. Collab and streaming are mutually - // exclusive at mount, so this only affects a stream begun after a collaborative open. - if (collaborationEnabled && !isStreaming && !wasStreamingRef.current) return + // Collaboration and agent-streaming are mutually exclusive: when collaborating the + // Y.Doc is the sole source of truth, so this manual reconcile loop stays fully inert + // — its `setContent` would sync a full-document replace into the shared doc (the + // ySyncPlugin writes it regardless of `emitUpdate: false`), wiping peers' concurrent + // edits. A stream begun after a collaborative open therefore does not drive this + // editor; editability is owned by the reactive effect above, which holds it read-only + // while `isStreaming`. + if (collaborationEnabled) return const syncEditorBody = (body: string) => { if (body === lastSyncedBodyRef.current) return lastSyncedBodyRef.current = body From 7b937c5a3d01238322d8a1ce12b7b0b45a7918c2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 18:41:59 -0700 Subject: [PATCH 11/13] fix(files): scope collaboration to the Files surface (disjoint from agent-streaming) Greptile P1 follow-up: disabling the stream reconcile in collab mode meant an agent streaming into a collaboratively-open file never showed its output. Root cause is that collaboration and agent-streaming are two different surfaces that should never share one editor: the Files page is the collaborative editing surface and never streams; the mothership/Chat surface streams agent output and should stay solo. They only collided because collaborationEnabled did not gate on surface. Add an explicit `collaborative` opt-in threaded FileViewer -> RichMarkdownEditor, set only by the Files page. Collaboration and streaming are now disjoint by construction, so the reconcile loop's collab guard is belt-and-suspenders and the agent surface streams normally (solo, as before collaboration existed). --- .../components/file-viewer/file-viewer.tsx | 8 ++++ .../rich-markdown-editor.tsx | 39 ++++++++++++------- .../workspace/[workspaceId]/files/files.tsx | 1 + 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9984e16b0df..b9bc4b4a302 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -110,6 +110,12 @@ interface FileViewerProps { streamIsIncremental?: boolean disableStreamingAutoScroll?: boolean previewContextKey?: string + /** + * Opt this surface into live collaborative editing (markdown files only). Set by the + * Files page; the agent/Chat surface leaves it off so collaboration and agent-streaming + * never target one editor. See {@link RichMarkdownEditorProps.collaborative}. + */ + collaborative?: boolean } export function FileViewer(props: FileViewerProps) { @@ -141,6 +147,7 @@ function FileViewerContent({ streamIsIncremental, disableStreamingAutoScroll = false, previewContextKey, + collaborative, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -185,6 +192,7 @@ function FileViewerContent({ streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + collaborative={collaborative} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index fff3ae0d5f5..b87eefc1823 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -83,6 +83,13 @@ interface RichMarkdownEditorProps { previewContextKey?: string /** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */ disableTagging?: boolean + /** + * Opt this surface into live collaborative editing. Set only by the Files page — + * the dedicated editing surface, which never streams agent output. The agent/Chat + * surface leaves it off, so collaboration and agent-streaming are disjoint by + * construction (they cannot both drive one editor and corrupt the shared doc). + */ + collaborative?: boolean } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -101,6 +108,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ disableStreamingAutoScroll = false, previewContextKey, disableTagging, + collaborative = false, }: RichMarkdownEditorProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' @@ -163,6 +171,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} disableTagging={disableTagging} + collaborative={collaborative} onChange={setDraftContent} onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} @@ -186,6 +195,8 @@ interface LoadedRichMarkdownEditorProps { streamIsIncremental?: boolean disableStreamingAutoScroll?: boolean disableTagging?: boolean + /** See {@link RichMarkdownEditorProps.collaborative}. */ + collaborative?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise /** Reports whether the collaborative document is synced+seeded (autosave gate). */ @@ -215,6 +226,7 @@ export function LoadedRichMarkdownEditor({ streamIsIncremental, disableStreamingAutoScroll, disableTagging, + collaborative = false, onChange, onSaveShortcut, onCollabReadyChange, @@ -231,11 +243,12 @@ export function LoadedRichMarkdownEditor({ * Collaboration is decided once at mount from synchronously-available inputs * (`settledRef` is set just above) via `useState`-init, and never changes — TipTap * fixes the extension set at editor creation, so it cannot turn on later. Enabled - * only for an editable, round-trip-safe, non-streaming workspace document with a - * known user. + * only on a `collaborative` surface (the Files page, which never streams) for an + * editable, round-trip-safe, non-streaming workspace document with a known user. */ const [collaborationEnabled] = useState( () => + collaborative && canEdit && !streamingAtMountRef.current && (settledRef.current?.verdict ?? false) && @@ -633,12 +646,9 @@ export function LoadedRichMarkdownEditor({ }, [collaboration, editor, onCollabReadyChange, setCollabReady]) /** - * Owns editability for the entire collaborative lifecycle: `useEditor`'s `editable` - * is only the initial value, and the streaming/settle effect stays inert in collab - * mode (they are mutually exclusive) — so re-apply here whenever `isEditable` flips, - * whether from collaboration readiness (synced + seeded) or a stream toggling - * `isStreaming`. `isEditable` already folds in `!isStreaming`, holding read-only for - * the duration of any agent stream over a collaborative document. + * Owns editability for the collaborative lifecycle: `useEditor`'s `editable` is only + * the initial value, and the streaming/settle effect stays inert in collab mode — so + * re-apply here whenever collaboration readiness (synced + seeded) flips `isEditable`. */ useEffect(() => { if (!editor || !collaborationEnabled) return @@ -669,13 +679,12 @@ export function LoadedRichMarkdownEditor({ const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return - // Collaboration and agent-streaming are mutually exclusive: when collaborating the - // Y.Doc is the sole source of truth, so this manual reconcile loop stays fully inert - // — its `setContent` would sync a full-document replace into the shared doc (the - // ySyncPlugin writes it regardless of `emitUpdate: false`), wiping peers' concurrent - // edits. A stream begun after a collaborative open therefore does not drive this - // editor; editability is owned by the reactive effect above, which holds it read-only - // while `isStreaming`. + // Collaboration and agent-streaming are disjoint surfaces: collaboration is enabled + // only on the Files page, which never streams agent output, so in collab mode this + // reconcile loop stays fully inert. It must not run even defensively — its + // `setContent` would sync a full-document replace into the shared Y.Doc (the + // ySyncPlugin writes it regardless of `emitUpdate: false`), wiping peers' edits. + // Editability is owned by the reactive effect above. if (collaborationEnabled) return const syncEditorBody = (body: string) => { if (body === lastSyncedBodyRef.current) return diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 9128bebf31d..29531265471 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1924,6 +1924,7 @@ export function Files() { onSaveStatusChange={handleSaveStatusChange} saveRef={saveRef} discardRef={discardRef} + collaborative /> Date: Fri, 24 Jul 2026 18:55:07 -0700 Subject: [PATCH 12/13] fix(files): arm outage fallback on socket disconnect, not provider absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor High: useSocket() returns a socket object even while disconnected, so FileDocProvider is always created (non-null) and the grace timer — which only armed when provider was null — never fired on a genuine outage, leaving the editor blank + read-only. Arm the grace timer regardless of provider presence and gate the fallback on the live socket connection state (new FileDocProvider.isConnected). A connected-but-slow sync no longer seeds here (which would race the server's seeder election); seedFromLoaded still self-guards on initialContentLoaded so a completed sync makes it a no-op. --- .../collaboration/file-doc-provider.test.ts | 8 ++++++++ .../collaboration/file-doc-provider.ts | 5 +++++ .../rich-markdown-editor.tsx | 17 ++++++++++++----- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 97fd5e13996..99c1c07cc54 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -61,6 +61,14 @@ describe('FileDocProvider', () => { }) }) + it('reflects the socket connection state via isConnected', () => { + const online = createProvider(true) + expect(online.provider.isConnected).toBe(true) + + const offline = createProvider(false) + expect(offline.provider.isConnected).toBe(false) + }) + it('waits for connect before joining when the socket is offline', () => { const { emit, fire } = createProvider(false) expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 5472ab31668..e91e8556628 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -80,6 +80,11 @@ export class FileDocProvider extends ObservableV2 { if (socket.connected) this.join() } + /** Whether the underlying socket is currently connected. */ + get isConnected(): boolean { + return this.socket.connected + } + /** Join the room, binding our client id so the server only accepts awareness we own. */ private join = () => { if (this.fatal) return diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index b87eefc1823..febe2afbcde 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -610,13 +610,19 @@ export function LoadedRichMarkdownEditor({ }) } + // Realtime-establish grace: on a genuine outage (socket never connects), show the + // loaded content read-only after a grace period so the user reads their file instead + // of a blank editor. The socket object exists even while disconnected, so gate on the + // live connection state — a connected-but-slow sync must not seed here (it would race + // the server's seeder election). `seedFromLoaded` also self-guards on + // `initialContentLoaded`, so a completed sync makes this a no-op; a reconnect re-runs + // this effect onto the normal server-seeded sync path. + const graceTimer = setTimeout(() => { + if (!provider || !provider.isConnected) seedFromLoaded() + }, COLLAB_ESTABLISH_GRACE_MS) + if (!provider) { - // Realtime is not yet available (socket still connecting, or a full outage). Stay - // read-only + gated. If the provider never arrives, a grace timer seeds the loaded - // content so the user reads their file instead of a blank editor; a reconnect - // re-runs this effect with a live provider onto the normal server-seeded sync path. setReady(false) - const graceTimer = setTimeout(seedFromLoaded, COLLAB_ESTABLISH_GRACE_MS) return () => clearTimeout(graceTimer) } @@ -637,6 +643,7 @@ export function LoadedRichMarkdownEditor({ if (provider.joinError) onJoinError(provider.joinError) return () => { + clearTimeout(graceTimer) provider.off('seed-request', onProgress) provider.off('synced', onProgress) provider.off('join-error', onJoinError) From bad7cdd1a9aee72454b51bc3ac724524b8085f77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 19:03:16 -0700 Subject: [PATCH 13/13] fix(files): remove unsafe outage seed fallback (duplicates content on reconnect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: the grace-timer fallback seeded the loaded markdown into the persistent local Y.Doc when the socket stayed disconnected past the grace period. If the room was already seeded by another collaborator, reconnecting merged those independent CRDT inserts with the populated server document, duplicating text and autosaving the merged result. There is no safe way to show content through the collaborative editor during an outage — any write into the bound Y.Doc syncs regardless of origin. So drop the fallback (and the isConnected getter added for it): a collaborative document is read-only until synced + seeded, full stop. Proper outage resilience (offline persistence / a reconnecting indicator) is a separate follow-up, not a workaround that can corrupt documents. --- .../collaboration/file-doc-provider.test.ts | 8 ------- .../collaboration/file-doc-provider.ts | 5 ----- .../rich-markdown-editor.tsx | 21 +------------------ 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 99c1c07cc54..97fd5e13996 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -61,14 +61,6 @@ describe('FileDocProvider', () => { }) }) - it('reflects the socket connection state via isConnected', () => { - const online = createProvider(true) - expect(online.provider.isConnected).toBe(true) - - const offline = createProvider(false) - expect(offline.provider.isConnected).toBe(false) - }) - it('waits for connect before joining when the socket is offline', () => { const { emit, fire } = createProvider(false) expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN, expect.anything()) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index e91e8556628..5472ab31668 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -80,11 +80,6 @@ export class FileDocProvider extends ObservableV2 { if (socket.connected) this.join() } - /** Whether the underlying socket is currently connected. */ - get isConnected(): boolean { - return this.socket.connected - } - /** Join the room, binding our client id so the server only accepts awareness we own. */ private join = () => { if (this.fatal) return diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index febe2afbcde..2b9514b4f6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -54,13 +54,6 @@ const EXTENSIONS = createMarkdownEditorExtensions({ /** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */ const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 const STREAM_REPARSE_THROTTLE_MS = 120 -/** - * How long a collaborative editor waits for the realtime provider before falling back - * to a read-only render of the loaded content. Long enough that a normally-connecting - * socket always attaches first (so this only trips on a genuine realtime outage), and - * matched to the server's seed election deadline. - */ -const COLLAB_ESTABLISH_GRACE_MS = 10_000 interface RichMarkdownEditorProps { file: WorkspaceFileRecord @@ -610,20 +603,9 @@ export function LoadedRichMarkdownEditor({ }) } - // Realtime-establish grace: on a genuine outage (socket never connects), show the - // loaded content read-only after a grace period so the user reads their file instead - // of a blank editor. The socket object exists even while disconnected, so gate on the - // live connection state — a connected-but-slow sync must not seed here (it would race - // the server's seeder election). `seedFromLoaded` also self-guards on - // `initialContentLoaded`, so a completed sync makes this a no-op; a reconnect re-runs - // this effect onto the normal server-seeded sync path. - const graceTimer = setTimeout(() => { - if (!provider || !provider.isConnected) seedFromLoaded() - }, COLLAB_ESTABLISH_GRACE_MS) - if (!provider) { setReady(false) - return () => clearTimeout(graceTimer) + return } const report = () => setReady(provider.synced && config.get('initialContentLoaded') === true) @@ -643,7 +625,6 @@ export function LoadedRichMarkdownEditor({ if (provider.joinError) onJoinError(provider.joinError) return () => { - clearTimeout(graceTimer) provider.off('seed-request', onProgress) provider.off('synced', onProgress) provider.off('join-error', onJoinError)