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/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..97fd5e13996 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -0,0 +1,256 @@ +/** + * @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 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', () => { + 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(provider.shouldSeed).toBe(false) + 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 and latches joinError after a non-retryable error', () => { + const { provider, emit, fire } = createProvider(true) + emit.mockClear() + + 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', () => { + 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..5472ab31668 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -0,0 +1,240 @@ +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 + /** + * 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 + /** + * 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 + * 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() + } + + /** Join the room, binding our client id so the server only accepts awareness we own. */ + private join = () => { + if (this.fatal) return + 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() + } + + /** + * 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 + 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 + if (data.retryable === false) { + this.fatal = true + this.joinError = data + } + this.emit('join-error', [data]) + } + + private handleSeedRequest = (data: SeedRequestPayload) => { + if (data.fileId !== this.fileId) return + this.shouldSeed = true + 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)) + } + 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 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) { + super.destroy() + return + } + this.disposed = true + + 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/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..9fe069b56d0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -0,0 +1,104 @@ +'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 — 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) + const awarenessRef = useRef(null) + if (enabled && docRef.current === null) { + docRef.current = new Y.Doc() + 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(() => { + return () => { + awarenessRef.current?.destroy() + docRef.current?.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..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 @@ -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 { withAlpha } from '@/lib/workspaces/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,38 @@ 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` (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, + selectionRender: (user) => { + const hex = typeof user.color === 'string' ? user.color : '#000000' + return { + class: 'collaboration-carets__selection', + style: `background-color: ${withAlpha(hex, 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..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 @@ -454,3 +454,57 @@ .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; + /* 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; +} + +.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..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 @@ -2,13 +2,15 @@ import { memo, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' -import type { JSONContent } from '@tiptap/core' +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' 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 +18,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 +44,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, }) @@ -71,6 +76,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. */ @@ -89,7 +101,20 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ disableStreamingAutoScroll = false, previewContextKey, disableTagging, + collaborative = false, }: RichMarkdownEditorProps) { + const { data: session, isPending: isSessionPending } = 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,9 +133,14 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ saveRef, discardRef, normalizeBaseline: normalizeMarkdownContent, + 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 ( @@ -128,12 +158,16 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ content={content} isStreaming={isStreamInteractionLocked} canEdit={canEdit} + userId={userId} + userName={userName} autoFocus={autoFocus} streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} disableTagging={disableTagging} + collaborative={collaborative} onChange={setDraftContent} onSaveShortcut={saveImmediately} + onCollabReadyChange={setCollabReady} /> ) }) @@ -146,13 +180,20 @@ 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 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). */ + onCollabReadyChange: (ready: boolean) => void } interface SettledContent { @@ -172,12 +213,16 @@ export function LoadedRichMarkdownEditor({ content, isStreaming, canEdit, + userId, + userName, autoFocus, streamIsIncremental, disableStreamingAutoScroll, disableTagging, + collaborative = false, 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) @@ -187,11 +232,48 @@ 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 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 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) && + Boolean(userId) && + (file.storageContext ?? 'workspace') === 'workspace' + ) + /** + * 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 = + canEdit && !isStreaming && (settledRef.current?.verdict ?? false) && collabReady + + const collaboration = useFileDocCollaboration({ + fileId: file.id, + userId, + userName, + enabled: collaborationEnabled, + }) - /** Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse. */ + /** + * 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 || collaborationEnabled + ? '' + : parseMarkdownToDoc(splitFrontmatter(content).body) ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -289,8 +371,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 +550,99 @@ 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. + */ + const seedContentRef = useRef(content) + seedContentRef.current = content + + /** + * 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; + * - **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) 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) => { + setCollabReady(ready) + onCollabReadyChange(ready) + } + if (!collaboration) { + setReady(true) + return + } + const { provider, doc } = collaboration + if (!editor) { + setReady(false) + return + } + const config = doc.getMap('config') + + const seedFromLoaded = () => { + if (config.get('initialContentLoaded') === true) return + doc.transact(() => { + editor.commands.setContent( + parseMarkdownToDoc(splitFrontmatter(seedContentRef.current).body), + { contentType: 'json', emitUpdate: false } + ) + config.set('initialContentLoaded', true) + }) + } + + if (!provider) { + setReady(false) + return + } + + 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) seedFromLoaded() + } + + provider.on('seed-request', onProgress) + provider.on('synced', onProgress) + provider.on('join-error', onJoinError) + config.observe(report) + onProgress() + if (provider.joinError) onJoinError(provider.joinError) + + return () => { + provider.off('seed-request', onProgress) + provider.off('synced', onProgress) + provider.off('join-error', onJoinError) + config.unobserve(report) + onCollabReadyChange(true) + } + }, [collaboration, editor, onCollabReadyChange, setCollabReady]) + + /** + * 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 + if (editor.isEditable !== isEditable) editor.setEditable(isEditable) + }, [editor, collaborationEnabled, isEditable]) + /** * 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 +667,13 @@ export function LoadedRichMarkdownEditor({ const lastStreamParseAtRef = useRef(0) useEffect(() => { if (!editor) return + // 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 lastSyncedBodyRef.current = body @@ -542,13 +743,22 @@ 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) - }, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll]) + if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict && collabReady) + }, [ + editor, + content, + isStreaming, + canEdit, + autoFocus, + disableStreamingAutoScroll, + collaborationEnabled, + collabReady, + ]) useEffect( () => () => { 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, 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 /> { - const colorMap = new Map() - let colorIndex = 0 - - for (const userId of userIds) { - if (!colorMap.has(userId)) { - colorMap.set(userId, colorIndex++) - } - } - - return colorMap -} 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=="],