diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx index 0fe708ae78..bb9751322f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx @@ -1,70 +1,30 @@ 'use client' import { type CSSProperties, useMemo } from 'react' +import Image from 'next/image' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getPresenceColors } from '@/lib/collaboration/presence-colors' interface AvatarProps { connectionId: string | number name?: string color?: string + avatarUrl?: string | null tooltipContent?: React.ReactNode | null size?: 'sm' | 'md' | 'lg' index?: number // Position in stack for z-index } -// Color palette inspired by the app's design -const APP_COLORS = [ - { from: '#4F46E5', to: '#7C3AED' }, // indigo to purple - { from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia - { from: '#EC4899', to: '#F97316' }, // pink to orange - { from: '#14B8A6', to: '#10B981' }, // teal to emerald - { from: '#6366F1', to: '#8B5CF6' }, // indigo to violet - { from: '#F59E0B', to: '#F97316' }, // amber to orange -] - -/** - * Generate a deterministic gradient based on a connection ID - */ -function generateGradient(connectionId: string | number): string { - // Convert connectionId to a number for consistent hashing - const numericId = - typeof connectionId === 'string' - ? Math.abs(connectionId.split('').reduce((a, b) => a + b.charCodeAt(0), 0)) - : connectionId - - // Use the numeric ID to select a color pair from our palette - const colorPair = APP_COLORS[numericId % APP_COLORS.length] - - // Add a slight rotation to the gradient based on connection ID for variety - const rotation = (numericId * 25) % 360 - - return `linear-gradient(${rotation}deg, ${colorPair.from}, ${colorPair.to})` -} - export function UserAvatar({ connectionId, name, color, + avatarUrl, tooltipContent, size = 'md', index = 0, }: AvatarProps) { - // Generate a deterministic gradient for this user based on connection ID - // Or use the provided color if available - const backgroundStyle = useMemo(() => { - if (color) { - // If a color is provided, create a gradient with it - const baseColor = color - const lighterShade = color.startsWith('#') - ? `${color}dd` // Add transparency for a lighter shade effect - : color - const darkerShade = color.startsWith('#') ? color : color - - return `linear-gradient(135deg, ${lighterShade}, ${darkerShade})` - } - // Otherwise, generate a gradient based on connectionId - return generateGradient(connectionId) - }, [connectionId, color]) + const { gradient } = useMemo(() => getPresenceColors(connectionId, color), [connectionId, color]) // Determine avatar size const sizeClass = { @@ -73,20 +33,39 @@ export function UserAvatar({ lg: 'h-9 w-9 text-sm', }[size] + const pixelSize = { + sm: 20, + md: 28, + lg: 36, + }[size] + const initials = name ? name.charAt(0).toUpperCase() : '?' + const hasAvatar = Boolean(avatarUrl) const avatarElement = (
- {initials} + {hasAvatar && avatarUrl ? ( + {name + ) : ( + initials + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx index a9533716ec..23f7d7aba7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { cn } from '@/lib/utils' import { ConnectionStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/connection-status/connection-status' import { UserAvatar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar' import { usePresence } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence' @@ -11,6 +12,7 @@ interface User { name?: string color?: string info?: string + avatarUrl?: string | null } interface UserAvatarStackProps { @@ -55,21 +57,19 @@ export function UserAvatarStack({ lg: '-space-x-2', }[size] - return ( -
- {/* Connection status - always check, shows when offline or operation errors */} - + const shouldShowAvatars = visibleUsers.length > 0 - {/* Only show avatar stack when there are multiple users (>1) */} - {users.length > 1 && ( -
- {/* Render visible user avatars */} + return ( +
+ {shouldShowAvatars && ( +
{visibleUsers.map((user, index) => ( ))} - {/* Render overflow indicator if there are more users */} {overflowCount > 0 && ( )} + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer.tsx new file mode 100644 index 0000000000..1ad456a953 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer.tsx @@ -0,0 +1,114 @@ +'use client' + +import { memo, useMemo } from 'react' +import { useViewport } from 'reactflow' +import { useSession } from '@/lib/auth-client' +import { getPresenceColors } from '@/lib/collaboration/presence-colors' +import { useSocket } from '@/contexts/socket-context' + +interface CursorPoint { + x: number + y: number +} + +interface CursorRenderData { + id: string + name: string + cursor: CursorPoint + gradient: string + accentColor: string +} + +const POINTER_OFFSET = { + x: 2, + y: 18, +} + +const LABEL_BACKGROUND = 'rgba(15, 23, 42, 0.88)' + +const CollaboratorCursorLayerComponent = () => { + const { presenceUsers } = useSocket() + const viewport = useViewport() + const session = useSession() + const currentUserId = session.data?.user?.id + + const cursors = useMemo(() => { + if (!presenceUsers.length) { + return [] + } + + return presenceUsers + .filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor)) + .filter((user) => user.userId !== currentUserId) + .map((user) => { + const cursor = user.cursor + const name = user.userName?.trim() || 'Collaborator' + const { gradient, accentColor } = getPresenceColors(user.userId) + + return { + id: user.socketId, + name, + cursor, + gradient, + accentColor, + } + }) + }, [currentUserId, presenceUsers]) + + if (!cursors.length) { + return null + } + + return ( +
+ {cursors.map(({ id, name, cursor, gradient, accentColor }) => { + const x = cursor.x * viewport.zoom + viewport.x + const y = cursor.y * viewport.zoom + viewport.y + + return ( +
+
+ + + + +
+ + {name} +
+
+
+ ) + })} +
+ ) +} + +export const CollaboratorCursorLayer = memo(CollaboratorCursorLayerComponent) +CollaboratorCursorLayer.displayName = 'CollaboratorCursorLayer' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts index 23afeebc4a..ef8928a9ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts @@ -8,7 +8,8 @@ interface SocketPresenceUser { socketId: string userId: string userName: string - cursor?: { x: number; y: number } + avatarUrl?: string | null + cursor?: { x: number; y: number } | null selection?: { type: 'block' | 'edge' | 'none'; id?: string } } @@ -18,6 +19,7 @@ type PresenceUser = { name?: string color?: string info?: string + avatarUrl?: string | null } interface UsePresenceReturn { @@ -48,6 +50,7 @@ export function usePresence(): UsePresenceReturn { name: user.userName, color: undefined, // Let the avatar component generate colors info: user.selection?.type ? `Editing ${user.selection.type}` : undefined, + avatarUrl: user.avatarUrl, })) }, [presenceUsers]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 24473ae628..7e0d270392 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -15,6 +15,7 @@ import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console/logger' import { TriggerUtils } from '@/lib/workflows/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack' import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' @@ -29,6 +30,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' +import { CollaboratorCursorLayer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { getNodeAbsolutePosition, @@ -39,6 +41,7 @@ import { updateNodeParent as updateNodeParentUtil, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { getBlock } from '@/blocks' +import { useSocket } from '@/contexts/socket-context' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useStreamCleanup } from '@/hooks/use-stream-cleanup' import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions' @@ -113,6 +116,7 @@ const WorkflowContent = React.memo(() => { const params = useParams() const router = useRouter() const { project, getNodes, fitView } = useReactFlow() + const { emitCursorUpdate } = useSocket() // Get workspace ID from the params const workspaceId = params.workspaceId as string @@ -1065,6 +1069,31 @@ const WorkflowContent = React.memo(() => { ] ) + const handleCanvasPointerMove = useCallback( + (event: React.PointerEvent) => { + const target = event.currentTarget as HTMLElement + const bounds = target.getBoundingClientRect() + + const position = project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }) + + emitCursorUpdate(position) + }, + [project, emitCursorUpdate] + ) + + const handleCanvasPointerLeave = useCallback(() => { + emitCursorUpdate(null) + }, [emitCursorUpdate]) + + useEffect(() => { + return () => { + emitCursorUpdate(null) + } + }, [emitCursorUpdate]) + // Handle drag over for ReactFlow canvas const onDragOver = useCallback( (event: React.DragEvent) => { @@ -1937,6 +1966,9 @@ const WorkflowContent = React.memo(() => { return (
+
+ +
@@ -1957,6 +1989,9 @@ const WorkflowContent = React.memo(() => { return (
+
+ +
@@ -1999,6 +2034,8 @@ const WorkflowContent = React.memo(() => { }} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} + onPointerMove={handleCanvasPointerMove} + onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} selectNodesOnDrag={false} nodesConnectable={effectivePermissions.canEdit} @@ -2021,6 +2058,7 @@ const WorkflowContent = React.memo(() => { autoPanOnConnect={effectivePermissions.canEdit} autoPanOnNodeDrag={effectivePermissions.canEdit} > + Personal diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx index 3dc9ed8062..8af6548975 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/custom-tools/custom-tools.tsx @@ -97,7 +97,7 @@ export function CustomTools() {

Custom Tools

-

+

Manage workspace-scoped custom tools for your agents

@@ -155,14 +155,14 @@ export function CustomTools() { key={tool.id} className='flex items-center justify-between gap-4 rounded-[8px] border bg-background p-4' > -
-
- +
+
+ {tool.title}
{tool.schema?.function?.description && ( -

+

{tool.schema.function.description}

)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index c919f3ae7c..d6242c4d96 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -672,7 +672,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { - + Workspace admins {workspaceAdmins.map((admin) => ( diff --git a/apps/sim/contexts/socket-context.tsx b/apps/sim/contexts/socket-context.tsx index 2e722709db..366bafa54a 100644 --- a/apps/sim/contexts/socket-context.tsx +++ b/apps/sim/contexts/socket-context.tsx @@ -26,7 +26,8 @@ interface PresenceUser { socketId: string userId: string userName: string - cursor?: { x: number; y: number } + avatarUrl?: string | null + cursor?: { x: number; y: number } | null selection?: { type: 'block' | 'edge' | 'none'; id?: string } } @@ -52,7 +53,7 @@ interface SocketContextType { ) => void emitVariableUpdate: (variableId: string, field: string, value: any, operationId?: string) => void - emitCursorUpdate: (cursor: { x: number; y: number }) => void + emitCursorUpdate: (cursor: { x: number; y: number } | null) => void emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void // Event handlers for receiving real-time updates onWorkflowOperation: (handler: (data: any) => void) => void @@ -707,14 +708,23 @@ export function SocketProvider({ children, user }: SocketProviderProps) { // Cursor throttling optimized for database connection health const lastCursorEmit = useRef(0) const emitCursorUpdate = useCallback( - (cursor: { x: number; y: number }) => { - if (socket && currentWorkflowId) { - const now = performance.now() - // Reduced to 30fps (33ms) to reduce database load while maintaining smooth UX - if (now - lastCursorEmit.current >= 33) { - socket.emit('cursor-update', { cursor }) - lastCursorEmit.current = now - } + (cursor: { x: number; y: number } | null) => { + if (!socket || !currentWorkflowId) { + return + } + + const now = performance.now() + + if (cursor === null) { + socket.emit('cursor-update', { cursor: null }) + lastCursorEmit.current = now + return + } + + // Reduced to 30fps (33ms) to reduce database load while maintaining smooth UX + if (now - lastCursorEmit.current >= 33) { + socket.emit('cursor-update', { cursor }) + lastCursorEmit.current = now } }, [socket, currentWorkflowId] diff --git a/apps/sim/lib/collaboration/presence-colors.ts b/apps/sim/lib/collaboration/presence-colors.ts new file mode 100644 index 0000000000..022db7e764 --- /dev/null +++ b/apps/sim/lib/collaboration/presence-colors.ts @@ -0,0 +1,82 @@ +const APP_COLORS = [ + { from: '#4F46E5', to: '#7C3AED' }, // indigo to purple + { from: '#7C3AED', to: '#C026D3' }, // purple to fuchsia + { from: '#EC4899', to: '#F97316' }, // pink to orange + { from: '#14B8A6', to: '#10B981' }, // teal to emerald + { from: '#6366F1', to: '#8B5CF6' }, // indigo to violet + { from: '#F59E0B', to: '#F97316' }, // amber to orange +] + +interface PresenceColorPalette { + gradient: string + accentColor: string + baseColor: string +} + +const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/ + +function hashIdentifier(identifier: string | number): number { + if (typeof identifier === 'number' && Number.isFinite(identifier)) { + return Math.abs(Math.trunc(identifier)) + } + + if (typeof identifier === 'string') { + return Math.abs(Array.from(identifier).reduce((acc, char) => acc + char.charCodeAt(0), 0)) + } + + return 0 +} + +function withAlpha(hexColor: string, alpha: number): string { + if (!HEX_COLOR_REGEX.test(hexColor)) { + return hexColor + } + + const normalized = hexColor.slice(1) + const expanded = + normalized.length === 3 + ? normalized + .split('') + .map((char) => `${char}${char}`) + .join('') + : normalized + + const r = Number.parseInt(expanded.slice(0, 2), 16) + const g = Number.parseInt(expanded.slice(2, 4), 16) + const b = Number.parseInt(expanded.slice(4, 6), 16) + + return `rgba(${r}, ${g}, ${b}, ${Math.min(Math.max(alpha, 0), 1)})` +} + +function buildGradient(fromColor: string, toColor: string, rotationSeed: number): string { + const rotation = (rotationSeed * 25) % 360 + return `linear-gradient(${rotation}deg, ${fromColor}, ${toColor})` +} + +export function getPresenceColors( + identifier: string | number, + explicitColor?: string +): PresenceColorPalette { + const paletteIndex = hashIdentifier(identifier) + + if (explicitColor) { + const normalizedColor = explicitColor.trim() + const lighterShade = HEX_COLOR_REGEX.test(normalizedColor) + ? withAlpha(normalizedColor, 0.85) + : normalizedColor + + return { + gradient: buildGradient(lighterShade, normalizedColor, paletteIndex), + accentColor: normalizedColor, + baseColor: lighterShade, + } + } + + const colorPair = APP_COLORS[paletteIndex % APP_COLORS.length] + + return { + gradient: buildGradient(colorPair.from, colorPair.to, paletteIndex), + accentColor: colorPair.to, + baseColor: colorPair.from, + } +} diff --git a/apps/sim/socket-server/handlers/presence.ts b/apps/sim/socket-server/handlers/presence.ts index 05462e8a37..7067a09ea0 100644 --- a/apps/sim/socket-server/handlers/presence.ts +++ b/apps/sim/socket-server/handlers/presence.ts @@ -30,6 +30,7 @@ export function setupPresenceHandlers( socketId: socket.id, userId: session.userId, userName: session.userName, + avatarUrl: session.avatarUrl, cursor, }) }) @@ -54,6 +55,7 @@ export function setupPresenceHandlers( socketId: socket.id, userId: session.userId, userName: session.userName, + avatarUrl: session.avatarUrl, selection, }) }) diff --git a/apps/sim/socket-server/handlers/workflow.ts b/apps/sim/socket-server/handlers/workflow.ts index d7d3e4a310..9c4d7e7009 100644 --- a/apps/sim/socket-server/handlers/workflow.ts +++ b/apps/sim/socket-server/handlers/workflow.ts @@ -1,3 +1,5 @@ +import { db, user } from '@sim/db' +import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console/logger' import { getWorkflowState } from '@/socket-server/database/operations' import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' @@ -80,6 +82,21 @@ export function setupWorkflowHandlers( const room = roomManager.getWorkflowRoom(workflowId)! room.activeConnections++ + let avatarUrl = socket.userImage || null + if (!avatarUrl) { + try { + const [userRecord] = await db + .select({ image: user.image }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + avatarUrl = userRecord?.image ?? null + } catch (error) { + logger.warn('Failed to load user avatar for presence', { userId, error }) + } + } + const userPresence: UserPresence = { userId, workflowId, @@ -88,11 +105,16 @@ export function setupWorkflowHandlers( joinedAt: Date.now(), lastActivity: Date.now(), role: userRole, + avatarUrl, } room.users.set(socket.id, userPresence) roomManager.setWorkflowForSocket(socket.id, workflowId) - roomManager.setUserSession(socket.id, { userId, userName }) + roomManager.setUserSession(socket.id, { + userId, + userName, + avatarUrl, + }) const workflowState = await getWorkflowState(workflowId) socket.emit('workflow-state', workflowState) diff --git a/apps/sim/socket-server/middleware/auth.ts b/apps/sim/socket-server/middleware/auth.ts index a7d28175b2..56de4676ca 100644 --- a/apps/sim/socket-server/middleware/auth.ts +++ b/apps/sim/socket-server/middleware/auth.ts @@ -10,6 +10,7 @@ export interface AuthenticatedSocket extends Socket { userName?: string userEmail?: string activeOrganizationId?: string + userImage?: string | null } // Enhanced authentication middleware @@ -53,6 +54,7 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: any) socket.userId = session.user.id socket.userName = session.user.name || session.user.email || 'Unknown User' socket.userEmail = session.user.email + socket.userImage = session.user.image || null socket.activeOrganizationId = session.session.activeOrganizationId || undefined next() diff --git a/apps/sim/socket-server/rooms/manager.ts b/apps/sim/socket-server/rooms/manager.ts index b436211385..5b6a429f27 100644 --- a/apps/sim/socket-server/rooms/manager.ts +++ b/apps/sim/socket-server/rooms/manager.ts @@ -31,6 +31,7 @@ export interface UserPresence { role: string cursor?: { x: number; y: number } selection?: { type: 'block' | 'edge' | 'none'; id?: string } + avatarUrl?: string | null } export interface WorkflowRoom { @@ -43,7 +44,10 @@ export interface WorkflowRoom { export class RoomManager { private workflowRooms = new Map() private socketToWorkflow = new Map() - private userSessions = new Map() + private userSessions = new Map< + string, + { userId: string; userName: string; avatarUrl?: string | null } + >() private io: Server constructor(io: Server) { @@ -237,11 +241,16 @@ export class RoomManager { this.socketToWorkflow.set(socketId, workflowId) } - getUserSession(socketId: string): { userId: string; userName: string } | undefined { + getUserSession( + socketId: string + ): { userId: string; userName: string; avatarUrl?: string | null } | undefined { return this.userSessions.get(socketId) } - setUserSession(socketId: string, session: { userId: string; userName: string }): void { + setUserSession( + socketId: string, + session: { userId: string; userName: string; avatarUrl?: string | null } + ): void { this.userSessions.set(socketId, session) }