From 49604b756b75d2177acc663f48af283987f157d5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 24 Jul 2026 12:33:35 -0700 Subject: [PATCH] feat(files): live presence avatars + live file tree via realtime rooms --- apps/realtime/src/handlers/index.ts | 2 + .../src/handlers/workspace-files.test.ts | 169 ++++++++++++++++ apps/realtime/src/handlers/workspace-files.ts | 184 ++++++++++++++++++ apps/realtime/src/rooms/types.ts | 5 + apps/realtime/src/routes/http.ts | 23 +++ .../workspaces/[id]/files/register/route.ts | 3 + .../components/presence/presence-avatars.tsx | 114 +++++++++++ .../workspace/[workspaceId]/files/files.tsx | 8 + .../files/hooks/use-workspace-files-room.ts | 122 ++++++++++++ .../workflow-item/avatars/avatars.tsx | 121 ++---------- .../hooks/queries/workspace-file-folders.ts | 2 +- apps/sim/lib/realtime/notify.ts | 38 ++++ .../orchestration/file-folder-lifecycle.ts | 9 + 13 files changed, 690 insertions(+), 110 deletions(-) create mode 100644 apps/realtime/src/handlers/workspace-files.test.ts create mode 100644 apps/realtime/src/handlers/workspace-files.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts create mode 100644 apps/sim/lib/realtime/notify.ts diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 6ded2e54741..91573f1a1fc 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -4,6 +4,7 @@ import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' +import { setupWorkspaceFilesHandlers } from '@/handlers/workspace-files' import type { AuthenticatedSocket } from '@/middleware/auth' import type { IRoomManager } from '@/rooms' @@ -13,5 +14,6 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) + setupWorkspaceFilesHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/workspace-files.test.ts b/apps/realtime/src/handlers/workspace-files.test.ts new file mode 100644 index 00000000000..9ecc943c8bb --- /dev/null +++ b/apps/realtime/src/handlers/workspace-files.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { IRoomManager } from '@/rooms' + +const { mockAuthorizeRoom } = vi.hoisted(() => ({ + mockAuthorizeRoom: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: vi.fn() }, + user: { image: 'image' }, +})) + +vi.mock('@sim/platform-authz/rooms', () => ({ + authorizeRoom: mockAuthorizeRoom, +})) + +import { setupWorkspaceFilesHandlers } from '@/handlers/workspace-files' + +interface JoinPayload { + workspaceId: string + folderId?: string | null + tabSessionId?: string +} + +function createSocket(overrides?: Record) { + const handlers: Record Promise | void> = {} + const socket = { + id: 'socket-1', + userId: 'user-1', + userName: 'Test User', + userImage: 'avatar.png', + on: vi.fn((event: string, handler: (payload: JoinPayload) => Promise | void) => { + handlers[event] = handler + }), + emit: vi.fn(), + join: vi.fn(), + leave: vi.fn(), + to: vi.fn().mockReturnValue({ emit: vi.fn() }), + ...overrides, + } + return { handlers, socket } +} + +function createRoomManager(overrides?: Partial): IRoomManager { + return { + isReady: vi.fn().mockReturnValue(true), + getRoomForSocket: vi.fn().mockResolvedValue(null), + getRoomsForSocket: vi.fn().mockResolvedValue([]), + removeUserFromRoom: vi.fn().mockResolvedValue(false), + removeSocketFromAllRooms: vi.fn().mockResolvedValue([]), + broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), + getRoomUsers: vi.fn().mockResolvedValue([]), + hasRoom: vi.fn().mockResolvedValue(false), + addUserToRoom: vi.fn().mockResolvedValue(undefined), + getUserSession: vi.fn().mockResolvedValue(null), + updateUserActivity: vi.fn().mockResolvedValue(undefined), + updateRoomLastModified: vi.fn().mockResolvedValue(undefined), + emitToRoom: vi.fn(), + getUniqueUserCount: vi.fn().mockResolvedValue(1), + getTotalActiveConnections: vi.fn().mockResolvedValue(0), + shutdown: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + io: { + in: vi.fn().mockReturnValue({ socketsLeave: vi.fn().mockResolvedValue(undefined) }), + }, + ...overrides, + } as unknown as IRoomManager +} + +describe('setupWorkspaceFilesHandlers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorizeRoom.mockResolvedValue({ + allowed: true, + status: 200, + workspaceId: 'ws-1', + workspacePermission: 'admin', + }) + }) + + it('rejects join when the socket is not authenticated', async () => { + const { socket, handlers } = createSocket({ userId: undefined, userName: undefined }) + setupWorkspaceFilesHandlers( + socket as unknown as Parameters[0], + createRoomManager() + ) + + await handlers['join-workspace-files']({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith('join-workspace-files-error', { + workspaceId: 'ws-1', + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + }) + + it('rejects join with a retryable error when realtime is unavailable', async () => { + const { socket, handlers } = createSocket() + setupWorkspaceFilesHandlers( + socket as unknown as Parameters[0], + createRoomManager({ isReady: vi.fn().mockReturnValue(false) }) + ) + + await handlers['join-workspace-files']({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + 'join-workspace-files-error', + expect.objectContaining({ code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true }) + ) + }) + + it('rejects join when workspace access is denied', async () => { + mockAuthorizeRoom.mockResolvedValue({ + allowed: false, + status: 403, + workspaceId: 'ws-1', + workspacePermission: null, + }) + const { socket, handlers } = createSocket() + setupWorkspaceFilesHandlers( + socket as unknown as Parameters[0], + createRoomManager() + ) + + await handlers['join-workspace-files']({ workspaceId: 'ws-1' }) + + expect(socket.emit).toHaveBeenCalledWith( + 'join-workspace-files-error', + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + }) + + it('joins the workspace files room and broadcasts presence on success', async () => { + const { socket, handlers } = createSocket() + const roomManager = createRoomManager({ + getRoomUsers: vi.fn().mockResolvedValue([]), + }) + setupWorkspaceFilesHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workspace-files']({ + workspaceId: 'ws-1', + folderId: 'folder-1', + tabSessionId: 'tab-1', + }) + + expect(socket.join).toHaveBeenCalledWith('workspace-files:ws-1') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-1' }, + 'socket-1', + expect.objectContaining({ userId: 'user-1', folderId: 'folder-1', role: 'admin' }) + ) + expect(socket.emit).toHaveBeenCalledWith( + 'join-workspace-files-success', + expect.objectContaining({ workspaceId: 'ws-1', socketId: 'socket-1' }) + ) + expect(roomManager.broadcastPresenceUpdate).toHaveBeenCalledWith({ + type: ROOM_TYPES.WORKSPACE_FILES, + id: 'ws-1', + }) + }) +}) diff --git a/apps/realtime/src/handlers/workspace-files.ts b/apps/realtime/src/handlers/workspace-files.ts new file mode 100644 index 00000000000..c88d403e44e --- /dev/null +++ b/apps/realtime/src/handlers/workspace-files.ts @@ -0,0 +1,184 @@ +import { db, user } from '@sim/db' +import { createLogger } from '@sim/logger' +import { authorizeRoom } from '@sim/platform-authz/rooms' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import { eq } from 'drizzle-orm' +import type { AuthenticatedSocket } from '@/middleware/auth' +import type { IRoomManager, UserPresence } from '@/rooms' + +const logger = createLogger('WorkspaceFilesHandlers') + +/** The workspace-files room ref for a workspace id. */ +const filesRoom = (workspaceId: string): RoomRef => ({ + type: ROOM_TYPES.WORKSPACE_FILES, + id: workspaceId, +}) + +interface JoinPayload { + workspaceId: string + folderId?: string | null + tabSessionId?: string +} + +async function resolveAvatarUrl( + socket: AuthenticatedSocket, + userId: string +): Promise { + if (socket.userImage) return socket.userImage + try { + const [record] = await db + .select({ image: user.image }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return record?.image ?? null + } catch (error) { + logger.warn('Failed to load user avatar for files presence', { userId, error }) + return null + } +} + +/** + * Presence handlers for the workspace file browser. Mirrors the workflow join + * flow but is workspace-scoped (room id = workspaceId) and read-only presence: + * there are no persisted file operations over the socket — file mutations go + * through the HTTP API, which fans out a `workspace-files-changed` event + * separately. The viewer's `folderId` is recorded at join (a future hook for + * folder-scoped presence); there is no cursor channel here yet. + */ +export function setupWorkspaceFilesHandlers( + socket: AuthenticatedSocket, + roomManager: IRoomManager +) { + socket.on( + 'join-workspace-files', + async ({ workspaceId, folderId, tabSessionId }: JoinPayload) => { + try { + const userId = socket.userId + const userName = socket.userName + + if (!userId || !userName) { + socket.emit('join-workspace-files-error', { + workspaceId, + error: 'Authentication required', + code: 'AUTHENTICATION_REQUIRED', + retryable: false, + }) + return + } + + if (!roomManager.isReady()) { + socket.emit('join-workspace-files-error', { + workspaceId, + error: 'Realtime unavailable', + code: 'ROOM_MANAGER_UNAVAILABLE', + retryable: true, + }) + return + } + + const room = filesRoom(workspaceId) + + let authorized: Awaited> + try { + authorized = await authorizeRoom({ userId, room, action: 'read' }) + } catch (error) { + logger.warn(`Error authorizing files room for ${userId}:`, error) + socket.emit('join-workspace-files-error', { + workspaceId, + error: 'Failed to verify workspace access', + code: 'VERIFY_ACCESS_FAILED', + retryable: true, + }) + return + } + + if (!authorized.allowed) { + socket.emit('join-workspace-files-error', { + workspaceId, + error: authorized.status === 404 ? 'Workspace not found' : 'Access denied to workspace', + code: authorized.status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED', + retryable: false, + }) + return + } + + // Leave a previously-joined files room if switching workspaces. + const currentRoom = await roomManager.getRoomForSocket( + socket.id, + ROOM_TYPES.WORKSPACE_FILES + ) + if (currentRoom && currentRoom.id !== workspaceId) { + socket.leave(roomName(currentRoom)) + await roomManager.removeUserFromRoom(currentRoom, socket.id) + await roomManager.broadcastPresenceUpdate(currentRoom) + } + + // Clean up the same user's stale socket from the same tab (e.g. a reconnect + // that raced the old socket's disconnect), so presence shows one entry. + if (tabSessionId) { + const existingUsers = await roomManager.getRoomUsers(room) + for (const existing of existingUsers) { + if ( + existing.socketId !== socket.id && + existing.userId === userId && + existing.tabSessionId === tabSessionId + ) { + await roomManager.removeUserFromRoom(room, existing.socketId) + await roomManager.io.in(existing.socketId).socketsLeave(roomName(room)) + } + } + } + + socket.join(roomName(room)) + + const presence: UserPresence = { + userId, + room, + userName, + socketId: socket.id, + tabSessionId, + joinedAt: Date.now(), + lastActivity: Date.now(), + role: authorized.workspacePermission ?? 'read', + folderId: folderId ?? null, + avatarUrl: await resolveAvatarUrl(socket, userId), + } + + await roomManager.addUserToRoom(room, socket.id, presence) + + const presenceUsers = await roomManager.getRoomUsers(room) + socket.emit('join-workspace-files-success', { + workspaceId, + socketId: socket.id, + presenceUsers, + }) + + await roomManager.broadcastPresenceUpdate(room) + + logger.info(`User ${userId} (${userName}) joined files room for workspace ${workspaceId}`) + } catch (error) { + logger.error('Error joining workspace files room:', error) + socket.emit('join-workspace-files-error', { + workspaceId, + error: 'Failed to join workspace files', + code: 'JOIN_FAILED', + retryable: true, + }) + } + } + ) + + socket.on('leave-workspace-files', async () => { + try { + if (!roomManager.isReady()) return + const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKSPACE_FILES) + if (!room) return + socket.leave(roomName(room)) + await roomManager.removeUserFromRoom(room, socket.id) + await roomManager.broadcastPresenceUpdate(room) + } catch (error) { + logger.error('Error leaving workspace files room:', error) + } + }) +} diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 67cee450832..80525195ef8 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -19,6 +19,11 @@ export interface UserPresence { cursor?: { x: number; y: number } selection?: { type: 'block' | 'edge' | 'none'; id?: string } avatarUrl?: string | null + /** + * The subfolder the user is viewing, recorded at join for room types that track + * a per-viewer location (e.g. the workspace file browser). `null` is the root. + */ + folderId?: string | null } /** diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 233bee4d174..d8db9473cb6 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'http' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' import { type IRoomManager, WorkflowRoomService } from '@/rooms' @@ -152,6 +153,28 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } + // Fan out a file-tree change to everyone viewing a workspace's files, so their + // browser refetches. File mutations happen over the HTTP API (not the socket); + // this is the lossy liveness signal — a missed one only means stale-until-refetch. + if (req.method === 'POST' && req.url === '/api/workspace-files-changed') { + try { + const body = await readRequestBody(req) + const { workspaceId } = JSON.parse(body) + if (typeof workspaceId === 'string' && workspaceId.length > 0) { + roomManager.emitToRoom( + { type: ROOM_TYPES.WORKSPACE_FILES, id: workspaceId }, + 'workspace-files-changed', + { workspaceId, timestamp: Date.now() } + ) + } + sendSuccess(res) + } catch (error) { + logger.error('Error handling workspace files changed notification:', error) + sendError(res, 'Failed to process files change notification') + } + return + } + res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts index fd29a6c9dfb..4ed0b90c285 100644 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -7,6 +7,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { FileConflictError, parseWorkspaceFileKey, @@ -63,6 +64,8 @@ export const POST = withRouteHandler( if (created) { logger.info(`Registered direct upload ${name} -> ${key}`) + await notifyWorkspaceFilesChanged(workspaceId) + captureServerEvent( userId, 'file_uploaded', diff --git a/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx b/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx new file mode 100644 index 00000000000..3698578f8af --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/presence/presence-avatars.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useMemo } from 'react' +import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@sim/emcn' +import { getUserColor } from '@/lib/workspaces/colors' + +/** Minimal presence shape the avatar stack renders — shared by workflow and files. */ +export interface PresenceAvatarUser { + socketId: string + userId: string + userName?: string + avatarUrl?: string | null +} + +interface UserAvatarProps { + user: PresenceAvatarUser + index: number +} + +/** + * A single collaborator avatar: their image, falling back to a colored circle + * with their initial. Wrapped in a name tooltip when the name is known. + */ +function UserAvatar({ user, index }: UserAvatarProps) { + const color = getUserColor(user.userId) + const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?' + + const avatarElement = ( + + {user.avatarUrl && ( + + )} + + {initials} + + + ) + + if (user.userName) { + return ( + + {avatarElement} + + {user.userName} + + + ) + } + + return avatarElement +} + +interface PresenceAvatarsProps { + /** Collaborators to show — already filtered to exclude the current socket. */ + users: PresenceAvatarUser[] + /** Max avatars before collapsing the remainder into a "+N" chip. */ + maxVisible?: number +} + +const DEFAULT_MAX_VISIBLE = 5 + +/** + * Overlapping stack of collaborator avatars for presence. Presentational only — + * the caller owns fetching/filtering presence (workflow sidebar item, files + * header, etc.), so the stack looks identical everywhere it appears. + */ +export function PresenceAvatars({ users, maxVisible = DEFAULT_MAX_VISIBLE }: PresenceAvatarsProps) { + const { visibleUsers, overflowCount } = useMemo(() => { + if (users.length === 0) { + return { visibleUsers: [] as PresenceAvatarUser[], overflowCount: 0 } + } + const visible = users.slice(0, maxVisible) + const overflow = Math.max(0, users.length - maxVisible) + // Reverse so the rightmost avatar stays stable as new ones reveal on the left. + return { visibleUsers: [...visible].reverse(), overflowCount: overflow } + }, [users, maxVisible]) + + if (visibleUsers.length === 0) { + return null + } + + return ( +
+ {overflowCount > 0 && ( + + + + + +{overflowCount} + + + + + {overflowCount} more user{overflowCount > 1 ? 's' : ''} + + + )} + {visibleUsers.map((user, index) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index c6bcecde52d..9128bebf31d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -61,6 +61,7 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' +import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' @@ -74,6 +75,7 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' +import { useWorkspaceFilesRoom } from '@/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { filesFilterParsers, @@ -203,6 +205,11 @@ export function Files() { const canEdit = userPermissions.canEdit === true const { config: permissionConfig } = usePermissionConfig() + const { otherUsers: filesPresenceUsers } = useWorkspaceFilesRoom( + workspaceId, + currentFolderId ?? null + ) + useEffect(() => { if (permissionConfig.hideFilesTab) { router.replace(`/workspace/${workspaceId}`) @@ -1959,6 +1966,7 @@ export function Files() { title='Files' breadcrumbs={listBreadcrumbs} actions={headerActionsConfig} + aside={} /> ([]) + + const tabSessionIdRef = useRef('') + if (!tabSessionIdRef.current) tabSessionIdRef.current = generateShortId() + const folderIdRef = useRef(folderId) + + useEffect(() => { + folderIdRef.current = folderId + }, [folderId]) + + useEffect(() => { + if (!socket || !workspaceId) return + + let retries = 0 + let retryTimer: ReturnType | null = null + + const join = () => { + socket.emit('join-workspace-files', { + workspaceId, + folderId: folderIdRef.current, + tabSessionId: tabSessionIdRef.current, + }) + } + + const handleJoinSuccess = (data: { + workspaceId: string + presenceUsers: PresenceUpdatePayload[] + }) => { + if (data.workspaceId !== workspaceId) return + retries = 0 + setPresenceUsers(data.presenceUsers ?? []) + } + const handleJoinError = (data: JoinErrorPayload) => { + if (data.workspaceId !== workspaceId) return + logger.warn('Failed to join workspace files room', { code: data.code, error: data.error }) + if (data.retryable && retries < MAX_JOIN_RETRIES) { + retries += 1 + retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries) + } + } + const handlePresence = (users: PresenceUpdatePayload[]) => setPresenceUsers(users ?? []) + const handleChanged = (data: { workspaceId: string }) => { + if (data.workspaceId === workspaceId) + invalidateWorkspaceFileBrowsers(queryClient, workspaceId) + } + + // Join now if the socket is already connected; `connect` covers (re)connects. + if (socket.connected) join() + socket.on('connect', join) + socket.on('join-workspace-files-success', handleJoinSuccess) + socket.on('join-workspace-files-error', handleJoinError) + socket.on('workspace-files:presence-update', handlePresence) + socket.on('workspace-files-changed', handleChanged) + + return () => { + if (retryTimer) clearTimeout(retryTimer) + socket.emit('leave-workspace-files') + socket.off('connect', join) + socket.off('join-workspace-files-success', handleJoinSuccess) + socket.off('join-workspace-files-error', handleJoinError) + socket.off('workspace-files:presence-update', handlePresence) + socket.off('workspace-files-changed', handleChanged) + setPresenceUsers([]) + } + }, [socket, workspaceId, queryClient]) + + const otherUsers = useMemo( + () => presenceUsers.filter((user) => user.socketId !== currentSocketId), + [presenceUsers, currentSocketId] + ) + + return { otherUsers } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx index 8626149a174..19b29832c61 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx @@ -1,8 +1,7 @@ 'use client' -import { type CSSProperties, useMemo } from 'react' -import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@sim/emcn' -import { getUserColor } from '@/lib/workspaces/colors' +import { useMemo } from 'react' +import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' import { useSocket } from '@/app/workspace/providers/socket-provider' import { SIDEBAR_WIDTH } from '@/stores/constants' import { usePresenceStore } from '@/stores/presence/store' @@ -21,64 +20,11 @@ interface AvatarsProps { workflowId: string } -interface PresenceUser { - socketId: string - userId: string - userName?: string - avatarUrl?: string | null -} - -interface UserAvatarProps { - user: PresenceUser - index: number -} - /** - * Individual user avatar using emcn Avatar component. - * Falls back to colored circle with initials if image fails to load. - */ -function UserAvatar({ user, index }: UserAvatarProps) { - const color = getUserColor(user.userId) - const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?' - - const avatarElement = ( - - {user.avatarUrl && ( - - )} - - {initials} - - - ) - - if (user.userName) { - return ( - - {avatarElement} - - {user.userName} - - - ) - } - - return avatarElement -} - -/** - * Displays user avatars for presence in a workflow item. - * Only shows avatars for the currently active workflow. - * - * @param props - Component props - * @returns Avatar stack for workflow presence + * Presence avatars for a workflow sidebar item. Owns the workflow-specific + * concerns — the presence source, the current-workflow filter, and the + * sidebar-width-driven visible count — and delegates the stack rendering to the + * shared {@link PresenceAvatars}, so it looks identical to every other surface. */ export function Avatars({ workflowId }: AvatarsProps) { const { currentWorkflowId, currentSocketId } = useSocket() @@ -86,8 +32,8 @@ export function Avatars({ workflowId }: AvatarsProps) { const sidebarWidth = useSidebarStore((state) => state.sidebarWidth) /** - * Calculate max visible avatars based on sidebar width. - * Scales between MIN_COUNT and MAX_COUNT as sidebar expands. + * Scale the max visible avatars between MIN_COUNT and MAX_COUNT as the sidebar + * widens. */ const maxVisible = useMemo(() => { const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN @@ -97,56 +43,13 @@ export function Avatars({ workflowId }: AvatarsProps) { }, [sidebarWidth]) /** - * Only show presence for the currently active workflow. - * Filter out the current socket connection (allows same user's other tabs to appear). + * Only show presence for the currently active workflow, excluding the current + * socket (so the user's own other tabs still appear). */ const workflowUsers = useMemo(() => { - if (currentWorkflowId !== workflowId) { - return [] - } + if (currentWorkflowId !== workflowId) return [] return presenceUsers.filter((user) => user.socketId !== currentSocketId) }, [presenceUsers, currentWorkflowId, workflowId, currentSocketId]) - /** - * Calculate visible users and overflow count. - * Shows up to maxVisible avatars, with overflow indicator for any remaining. - * Users are reversed so new avatars appear on the left (keeping right side stable). - */ - const { visibleUsers, overflowCount } = useMemo(() => { - if (workflowUsers.length === 0) { - return { visibleUsers: [], overflowCount: 0 } - } - - const visible = workflowUsers.slice(0, maxVisible) - const overflow = Math.max(0, workflowUsers.length - maxVisible) - - // Reverse so rightmost avatars stay stable as new ones are revealed on the left - return { visibleUsers: [...visible].reverse(), overflowCount: overflow } - }, [workflowUsers, maxVisible]) - - if (visibleUsers.length === 0) { - return null - } - - return ( -
- {overflowCount > 0 && ( - - - - - +{overflowCount} - - - - - {overflowCount} more user{overflowCount > 1 ? 's' : ''} - - - )} - {visibleUsers.map((user, index) => ( - - ))} -
- ) + return } diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index bad1c52c0ee..357a6b8b3af 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -40,7 +40,7 @@ async function fetchWorkspaceFileFolders( return data.folders } -function invalidateWorkspaceFileBrowsers( +export function invalidateWorkspaceFileBrowsers( queryClient: ReturnType, workspaceId: string ) { diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts new file mode 100644 index 00000000000..e738130b6de --- /dev/null +++ b/apps/sim/lib/realtime/notify.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { getSocketServerUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('RealtimeNotify') + +/** Bound the wait on the realtime server so a slow/hung socket pod can't stall a file mutation. */ +const NOTIFY_TIMEOUT_MS = 2000 + +/** + * Best-effort fan-out to the realtime server that a workspace's file tree changed, + * so every browser currently viewing that workspace's files refetches. File + * mutations happen over the HTTP API (not the socket); this is the lossy liveness + * signal — a dropped notification only degrades to stale-until-refetch, so it must + * never throw or block the originating mutation. + */ +export async function notifyWorkspaceFilesChanged(workspaceId: string): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workspace-files-changed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workspaceId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workspace-files-changed notify failed', { + workspaceId, + status: response.status, + }) + } + } catch (error) { + logger.warn('workspace-files-changed notify error', { + workspaceId, + error: getErrorMessage(error), + }) + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index ce11157ccb7..4abfd980bcf 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, @@ -194,6 +195,7 @@ export async function performDeleteWorkspaceFileItems( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) @@ -254,6 +256,7 @@ export async function performMoveWorkspaceFileItems( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, movedItems } } catch (error) { logger.error('Failed to move workspace file items', { error }) @@ -298,6 +301,7 @@ export async function performRenameWorkspaceFile( description: `Renamed file to "${file.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, file } } catch (error) { logger.error('Failed to rename workspace file', { error }) @@ -361,6 +365,7 @@ export async function performMoveRenameWorkspaceFile( }) } + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, file } } catch (error) { logger.error('Failed to move/rename workspace file', { error }) @@ -394,6 +399,7 @@ export async function performRestoreWorkspaceFile( description: `Restored workspace file ${fileId}`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true } } catch (error) { logger.error('Failed to restore workspace file', { error }) @@ -424,6 +430,7 @@ export async function performCreateWorkspaceFileFolder( description: `Created file folder "${folder.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder } } catch (error) { logger.error('Failed to create workspace file folder', { error }) @@ -463,6 +470,7 @@ export async function performUpdateWorkspaceFileFolder( description: `Updated file folder "${folder.name}"`, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder } } catch (error) { logger.error('Failed to update workspace file folder', { error }) @@ -509,6 +517,7 @@ export async function performRestoreWorkspaceFileFolder( }, }) + await notifyWorkspaceFilesChanged(workspaceId) return { success: true, folder, restoredItems } } catch (error) { logger.error('Failed to restore workspace file folder', { error })