Skip to content

Commit f9b557b

Browse files
authored
fix(files): make embedded resource file view collaborative (#6095)
* fix(files): make embedded resource file view collaborative 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. * fix(files): refcount file-doc room membership per shared socket 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).
1 parent 28a26c1 commit f9b557b

3 files changed

Lines changed: 93 additions & 1 deletion

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,51 @@ describe('FileDocProvider', () => {
261261
expect(emittedMessages(emit)).toHaveLength(0)
262262
})
263263

264+
it('leaves the room only when the LAST provider for a file on a shared socket is destroyed', () => {
265+
// Two surfaces in one tab (Files editor + embedded chat panel) share one socket and both open the
266+
// same file. Tearing the first down must NOT strand the second — the server drops the socket from
267+
// the room on any LEAVE, so LEAVE may fire only when the last provider goes away.
268+
const { socket, emit } = createSocket(true)
269+
const docA = new Y.Doc()
270+
const docB = new Y.Doc()
271+
const first = new FileDocProvider(
272+
socket,
273+
'shared-file',
274+
docA,
275+
new awarenessProtocol.Awareness(docA)
276+
)
277+
const second = new FileDocProvider(
278+
socket,
279+
'shared-file',
280+
docB,
281+
new awarenessProtocol.Awareness(docB)
282+
)
283+
emit.mockClear()
284+
285+
first.destroy()
286+
expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything())
287+
288+
second.destroy()
289+
expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'shared-file' })
290+
})
291+
292+
it('scopes the shared-membership refcount per file (a sibling file leaves independently)', () => {
293+
const { socket, emit } = createSocket(true)
294+
const docA = new Y.Doc()
295+
const docB = new Y.Doc()
296+
const fileA = new FileDocProvider(socket, 'file-a', docA, new awarenessProtocol.Awareness(docA))
297+
const fileB = new FileDocProvider(socket, 'file-b', docB, new awarenessProtocol.Awareness(docB))
298+
emit.mockClear()
299+
300+
fileA.destroy()
301+
// A different file's sole provider still leaves immediately.
302+
expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-a' })
303+
expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' })
304+
305+
fileB.destroy()
306+
expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'file-b' })
307+
})
308+
264309
it('gives up with a non-retryable join-error when the first sync never arrives (offline)', () => {
265310
vi.useFakeTimers()
266311
try {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@ interface FileDocProviderEvents {
4040
*/
4141
const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs
4242

43+
/**
44+
* Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the
45+
* embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider
46+
* for the same file JOIN the same room over that socket. The server's `leave(name)` drops the socket
47+
* from the room outright — no membership refcount — so the FIRST provider's `destroy()` would strand
48+
* the second (still-mounted) one: no more content or presence updates. Keyed by the {@link Socket}
49+
* OBJECT (stable across reconnects, unlike `socket.id`), so the count survives a reconnect.
50+
*
51+
* The single-provider case is unchanged: the count goes `0 → 1 → 0` and `LEAVE` fires exactly as
52+
* before. `LEAVE` is emitted only when the LAST provider for a file on a socket tears down.
53+
*/
54+
const roomJoinCounts = new WeakMap<Socket, Map<string, number>>()
55+
56+
/** Record another live provider for `fileId` on `socket` (called at construction). */
57+
function retainRoomMembership(socket: Socket, fileId: string): void {
58+
let counts = roomJoinCounts.get(socket)
59+
if (!counts) {
60+
counts = new Map()
61+
roomJoinCounts.set(socket, counts)
62+
}
63+
counts.set(fileId, (counts.get(fileId) ?? 0) + 1)
64+
}
65+
66+
/**
67+
* Drop one live provider for `fileId` on `socket` (called at teardown). Returns `true` when this was
68+
* the last one — i.e. the caller should emit `LEAVE` so the socket leaves the room.
69+
*/
70+
function releaseRoomMembership(socket: Socket, fileId: string): boolean {
71+
const counts = roomJoinCounts.get(socket)
72+
const next = (counts?.get(fileId) ?? 1) - 1
73+
if (next > 0) {
74+
counts?.set(fileId, next)
75+
return false
76+
}
77+
counts?.delete(fileId)
78+
return true
79+
}
80+
4381
/**
4482
* The client half of the collaborative file-document protocol: a Yjs provider
4583
* that carries document sync + awareness over the shared, already-authenticated
@@ -98,6 +136,10 @@ export class FileDocProvider extends ObservableV2<FileDocProviderEvents> {
98136
// Watch the seed flag so reaching "seeded" (server seed applied) can clear the readiness deadline.
99137
doc.getMap(FILE_DOC_SEED.configMap).observe(this.handleConfigChange)
100138

139+
// Count this provider against the shared socket's membership of the file's room, so the room is
140+
// left only when the last provider for this file tears down (see {@link releaseRoomMembership}).
141+
retainRoomMembership(socket, fileId)
142+
101143
if (socket.connected) this.join()
102144

103145
// 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<FileDocProviderEvents> {
304346

305347
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'provider-destroy')
306348

307-
this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId })
349+
// Only actually leave the room when this was the last provider for the file on the shared socket —
350+
// otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded.
351+
if (releaseRoomMembership(this.socket, this.fileId)) {
352+
this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId })
353+
}
308354
this.socket.off(FILE_DOC_EVENTS.MESSAGE, this.handleMessage)
309355
this.socket.off(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess)
310356
this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,7 @@ function EmbeddedFile({
720720
streamIsIncremental={streamIsIncremental}
721721
disableStreamingAutoScroll={disableStreamingAutoScroll}
722722
previewContextKey={previewContextKey}
723+
collaborative
723724
/>
724725
</div>
725726
)

0 commit comments

Comments
 (0)