From 297a562245cd21f4d7b1f67aa042876d64f5e91b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 30 Jul 2026 11:09:46 -0700 Subject: [PATCH 1/2] fix(files): make embedded resource file view collaborative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /chat resource panel rendered saved files through FileViewer without the collaborative opt-in, so a file open on the Files page and the same file open in the embedded panel never joined the same file-doc room — no live carets and no live content sync between the two surfaces. Pass collaborative on the EmbeddedFile FileViewer. Collaboration still self-gates on canEdit + non-streaming + workspace doc, so the agent token-stream preview (the dedicated streaming-file path, canEdit=false) is untouched. --- .../components/resource-content/resource-content.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 0f5bd7849df..7af698dd71d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -720,6 +720,7 @@ function EmbeddedFile({ streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} + collaborative /> ) From 60db01bcc6335de181ad23a80775ce6f27fddbf8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 30 Jul 2026 11:19:23 -0700 Subject: [PATCH 2/2] fix(files): refcount file-doc room membership per shared socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two collaborative surfaces in one tab (the Files editor and the embedded chat resource panel) share one Socket.IO connection, so both providers for the same file JOIN the same room over that socket. The server's LEAVE does socket.leave(name) with no membership refcount, so the first provider's destroy() would strand the second still-mounted one — no more live content or presence. Count live providers per file per socket (keyed by the stable Socket object, so it survives reconnects) and emit LEAVE only when the last provider for a file tears down. The single-provider path is unchanged (0->1->0). --- .../collaboration/file-doc-provider.test.ts | 45 +++++++++++++++++ .../collaboration/file-doc-provider.ts | 48 ++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) 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 29e31b53301..af4346011ff 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 @@ -261,6 +261,51 @@ describe('FileDocProvider', () => { expect(emittedMessages(emit)).toHaveLength(0) }) + it('leaves the room only when the LAST provider for a file on a shared socket is destroyed', () => { + // Two surfaces in one tab (Files editor + embedded chat panel) share one socket and both open the + // same file. Tearing the first down must NOT strand the second — the server drops the socket from + // the room on any LEAVE, so LEAVE may fire only when the last provider goes away. + const { socket, emit } = createSocket(true) + const docA = new Y.Doc() + const docB = new Y.Doc() + const first = new FileDocProvider( + socket, + 'shared-file', + docA, + new awarenessProtocol.Awareness(docA) + ) + const second = new FileDocProvider( + socket, + 'shared-file', + docB, + new awarenessProtocol.Awareness(docB) + ) + emit.mockClear() + + first.destroy() + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything()) + + second.destroy() + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'shared-file' }) + }) + + it('scopes the shared-membership refcount per file (a sibling file leaves independently)', () => { + const { socket, emit } = createSocket(true) + const docA = new Y.Doc() + const docB = new Y.Doc() + const fileA = new FileDocProvider(socket, 'file-a', docA, new awarenessProtocol.Awareness(docA)) + const fileB = new FileDocProvider(socket, 'file-b', docB, new awarenessProtocol.Awareness(docB)) + emit.mockClear() + + fileA.destroy() + // A different file's sole provider still leaves immediately. + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-a' }) + expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' }) + + fileB.destroy() + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' }) + }) + it('gives up with a non-retryable join-error when the first sync never arrives (offline)', () => { vi.useFakeTimers() try { 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 6049616f2ba..d23b3b21d49 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 @@ -40,6 +40,44 @@ interface FileDocProviderEvents { */ const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs +/** + * Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the + * embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider + * for the same file JOIN the same room over that socket. The server's `leave(name)` drops the socket + * from the room outright — no membership refcount — so the FIRST provider's `destroy()` would strand + * the second (still-mounted) one: no more content or presence updates. Keyed by the {@link Socket} + * OBJECT (stable across reconnects, unlike `socket.id`), so the count survives a reconnect. + * + * The single-provider case is unchanged: the count goes `0 → 1 → 0` and `LEAVE` fires exactly as + * before. `LEAVE` is emitted only when the LAST provider for a file on a socket tears down. + */ +const roomJoinCounts = new WeakMap>() + +/** Record another live provider for `fileId` on `socket` (called at construction). */ +function retainRoomMembership(socket: Socket, fileId: string): void { + let counts = roomJoinCounts.get(socket) + if (!counts) { + counts = new Map() + roomJoinCounts.set(socket, counts) + } + counts.set(fileId, (counts.get(fileId) ?? 0) + 1) +} + +/** + * Drop one live provider for `fileId` on `socket` (called at teardown). Returns `true` when this was + * the last one — i.e. the caller should emit `LEAVE` so the socket leaves the room. + */ +function releaseRoomMembership(socket: Socket, fileId: string): boolean { + const counts = roomJoinCounts.get(socket) + const next = (counts?.get(fileId) ?? 1) - 1 + if (next > 0) { + counts?.set(fileId, next) + return false + } + counts?.delete(fileId) + return true +} + /** * The client half of the collaborative file-document protocol: a Yjs provider * that carries document sync + awareness over the shared, already-authenticated @@ -98,6 +136,10 @@ export class FileDocProvider extends ObservableV2 { // Watch the seed flag so reaching "seeded" (server seed applied) can clear the readiness deadline. doc.getMap(FILE_DOC_SEED.configMap).observe(this.handleConfigChange) + // Count this provider against the shared socket's membership of the file's room, so the room is + // left only when the last provider for this file tears down (see {@link releaseRoomMembership}). + retainRoomMembership(socket, fileId) + if (socket.connected) this.join() // Arm the fallback: if we don't reach readiness (synced + seeded) before the deadline, give up. @@ -304,7 +346,11 @@ export class FileDocProvider extends ObservableV2 { awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy') - this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) + // Only actually leave the room when this was the last provider for the file on the shared socket — + // otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded. + if (releaseRoomMembership(this.socket, this.fileId)) { + 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)