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 = (
+
Manage workspace-scoped custom tools for your agents
+
+
+
{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)
}