diff --git a/apps/backend/supabase/migrations/0008_user_presence.sql b/apps/backend/supabase/migrations/0008_user_presence.sql
new file mode 100644
index 0000000000..5e0ce8cb39
--- /dev/null
+++ b/apps/backend/supabase/migrations/0008_user_presence.sql
@@ -0,0 +1,110 @@
+CREATE TABLE IF NOT EXISTS public.user_presence (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+ last_seen timestamp with time zone DEFAULT now() NOT NULL,
+ is_online boolean DEFAULT true NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
+ UNIQUE(project_id, user_id)
+);
+
+ALTER TABLE public.user_presence ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can view presence for accessible projects" ON public.user_presence
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.user_projects
+ WHERE user_projects.project_id = user_presence.project_id
+ AND user_projects.user_id = auth.uid()
+ )
+ );
+
+CREATE POLICY "Users can manage their own presence" ON public.user_presence
+ FOR ALL
+ USING (user_id = auth.uid())
+ WITH CHECK (user_id = auth.uid());
+
+CREATE OR REPLACE FUNCTION public.update_user_presence(
+ p_project_id uuid,
+ p_user_id uuid,
+ p_is_online boolean DEFAULT true
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ INSERT INTO public.user_presence (project_id, user_id, is_online, last_seen, updated_at)
+ VALUES (p_project_id, p_user_id, p_is_online, now(), now())
+ ON CONFLICT (project_id, user_id)
+ DO UPDATE SET
+ is_online = p_is_online,
+ last_seen = now(),
+ updated_at = now();
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.cleanup_offline_users()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ UPDATE public.user_presence
+ SET is_online = false, updated_at = now()
+ WHERE is_online = true
+ AND last_seen < now() - interval '5 minutes';
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.presence_changes()
+RETURNS TRIGGER
+SECURITY DEFINER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ topic_project_id uuid;
+BEGIN
+ topic_project_id := COALESCE(NEW.project_id, OLD.project_id);
+
+ IF topic_project_id IS NOT NULL THEN
+ PERFORM realtime.broadcast_changes(
+ 'presence:' || topic_project_id::text,
+ TG_OP,
+ TG_OP,
+ TG_TABLE_NAME,
+ TG_TABLE_SCHEMA,
+ NEW,
+ OLD
+ );
+ END IF;
+
+ RETURN NULL;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS handle_presence_changes ON public.user_presence;
+CREATE TRIGGER handle_presence_changes
+AFTER INSERT OR UPDATE OR DELETE
+ON public.user_presence
+FOR EACH ROW
+EXECUTE FUNCTION presence_changes();
+
+DROP POLICY IF EXISTS "Authenticated users can receive presence broadcasts" ON "realtime"."messages";
+CREATE POLICY "Authenticated users can receive presence broadcasts"
+ON "realtime"."messages"
+FOR SELECT
+TO authenticated
+USING (
+ CASE
+ WHEN payload->>'table' = 'user_presence' THEN
+ EXISTS (
+ SELECT 1 FROM public.user_projects
+ WHERE user_projects.project_id = (payload->>'project_id')::uuid
+ AND user_projects.user_id = auth.uid()
+ )
+ ELSE false
+ END
+);
diff --git a/apps/web/client/src/app/project/[id]/_components/presence-display.tsx b/apps/web/client/src/app/project/[id]/_components/presence-display.tsx
new file mode 100644
index 0000000000..1d8f501433
--- /dev/null
+++ b/apps/web/client/src/app/project/[id]/_components/presence-display.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { usePresenceManager } from '@/components/store/presence';
+import { useEditorEngine } from '@/components/store/editor';
+import { Avatar, AvatarFallback, AvatarImage } from '@onlook/ui/avatar';
+import { Button } from '@onlook/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip';
+import { observer } from 'mobx-react-lite';
+import { useEffect } from 'react';
+
+interface PresenceDisplayProps {
+ className?: string;
+}
+
+export const PresenceDisplay = observer(({ className = '' }: PresenceDisplayProps) => {
+ const presenceManager = usePresenceManager();
+ const editorEngine = useEditorEngine();
+
+ useEffect(() => {
+ if (editorEngine.user && editorEngine.projectId) {
+ presenceManager.setContext(editorEngine.user.id, editorEngine.projectId);
+ }
+ }, [editorEngine.user?.id, editorEngine.projectId, presenceManager]);
+
+ if (!presenceManager.isConnected || presenceManager.otherOnlineUsers.length === 0) {
+ return null;
+ }
+
+ const onlineUsers = presenceManager.otherOnlineUsers;
+
+ return (
+
+ {onlineUsers.slice(0, 3).map((user) => (
+
+
+
+
+
+
+ {user.displayName.split(' ').map(n => n[0]).join('').slice(0, 2)}
+
+
+
+
+
+
+ {user.displayName} is online
+
+
+ ))}
+
+ {onlineUsers.length > 3 && (
+
+
+
+
+
+
+ {onlineUsers.slice(3).map(user => (
+
{user.displayName}
+ ))}
+
+
+
+ )}
+
+ );
+});
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx
index ccc31fbd23..4622903a60 100644
--- a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx
+++ b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx
@@ -15,6 +15,7 @@ import { motion } from 'motion/react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Members } from '../members';
+import { PresenceDisplay } from '../presence-display';
import { BranchDisplay } from './branch';
import { ModeToggle } from './mode-toggle';
import { ProjectBreadcrumb } from './project-breadcrumb';
@@ -53,6 +54,7 @@ export const TopBar = observer(() => {
+
diff --git a/apps/web/client/src/app/project/[id]/providers.tsx b/apps/web/client/src/app/project/[id]/providers.tsx
index c8d57925ec..b8af2f06fd 100644
--- a/apps/web/client/src/app/project/[id]/providers.tsx
+++ b/apps/web/client/src/app/project/[id]/providers.tsx
@@ -2,6 +2,7 @@
import { EditorEngineProvider } from '@/components/store/editor';
import { HostingProvider } from '@/components/store/hosting';
+import { PresenceProvider } from '@/components/store/presence';
import type { Branch, Project } from '@onlook/models';
export const ProjectProviders = ({
@@ -16,7 +17,9 @@ export const ProjectProviders = ({
return (
- {children}
+
+ {children}
+
);
diff --git a/apps/web/client/src/components/store/presence/index.ts b/apps/web/client/src/components/store/presence/index.ts
new file mode 100644
index 0000000000..dfe29b77e3
--- /dev/null
+++ b/apps/web/client/src/components/store/presence/index.ts
@@ -0,0 +1 @@
+export { PresenceProvider, usePresenceManager } from './provider';
diff --git a/apps/web/client/src/components/store/presence/manager.ts b/apps/web/client/src/components/store/presence/manager.ts
new file mode 100644
index 0000000000..f19613f4c9
--- /dev/null
+++ b/apps/web/client/src/components/store/presence/manager.ts
@@ -0,0 +1,163 @@
+import { makeAutoObservable, reaction } from 'mobx';
+import { api } from '@/trpc/react';
+import { createClient } from '@/utils/supabase/client';
+
+export interface PresenceUser {
+ userId: string;
+ displayName: string;
+ avatarUrl?: string | null;
+ lastSeen: Date;
+}
+
+export class PresenceManager {
+ onlineUsers: PresenceUser[] = [];
+ currentUserId: string | null = null;
+ currentProjectId: string | null = null;
+ isConnected = false;
+
+ isLoading = false;
+
+ private subscription: any = null;
+
+ constructor() {
+ makeAutoObservable(this);
+
+ reaction(
+ () => this.currentProjectId,
+ (projectId) => {
+ if (projectId) {
+ this.joinProject(projectId);
+ } else {
+ this.leaveProject();
+ }
+ }
+ );
+ }
+
+ setContext(userId: string, projectId: string) {
+ this.currentUserId = userId;
+ this.currentProjectId = projectId;
+ }
+
+ async joinProject(projectId: string) {
+ if (!this.currentUserId) return;
+
+ try {
+ this.isLoading = true;
+ this.currentProjectId = projectId;
+
+ await api.presence.joinProject.mutate({ projectId });
+
+ await this.loadProjectPresence(projectId);
+ this.subscribeToPresenceUpdates(projectId);
+
+ this.isConnected = true;
+ } catch (error) {
+ console.error('Error joining project:', error);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+
+ async leaveProject() {
+ if (!this.currentProjectId || !this.currentUserId) return;
+
+ try {
+ await api.presence.leaveProject.mutate({ projectId: this.currentProjectId });
+
+ this.unsubscribeFromPresenceUpdates();
+ this.onlineUsers = [];
+ this.currentProjectId = null;
+ this.isConnected = false;
+ } catch (error) {
+ console.error('Error leaving project:', error);
+ }
+ }
+
+ private async loadProjectPresence(projectId: string) {
+ try {
+ const presenceData = await api.presence.getProjectPresence.query({ projectId });
+ this.onlineUsers = presenceData.map(p => ({
+ userId: p.userId,
+ displayName: p.displayName,
+ avatarUrl: p.avatarUrl,
+ lastSeen: p.lastSeen,
+ }));
+ } catch (error) {
+ console.error('Error loading project presence:', error);
+ }
+ }
+
+ private subscribeToPresenceUpdates(projectId: string) {
+ this.unsubscribeFromPresenceUpdates();
+
+ const supabase = createClient();
+ const channel = supabase
+ .channel(`presence:${projectId}`)
+ .on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'user_presence',
+ filter: `project_id=eq.${projectId}`,
+ },
+ (payload) => {
+ this.handlePresenceUpdate(payload);
+ }
+ )
+ .subscribe();
+
+ this.subscription = channel;
+ }
+
+ private unsubscribeFromPresenceUpdates() {
+ if (this.subscription) {
+ this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+ }
+
+ private handlePresenceUpdate(payload: any) {
+ const { eventType, new: newRecord, old: oldRecord } = payload;
+
+ switch (eventType) {
+ case 'INSERT':
+ case 'UPDATE':
+ if (newRecord && newRecord.is_online) {
+ const existingIndex = this.onlineUsers.findIndex(u => u.userId === newRecord.user_id);
+ const user: PresenceUser = {
+ userId: newRecord.user_id,
+ displayName: newRecord.user?.display_name || `${newRecord.user?.first_name || ''} ${newRecord.user?.last_name || ''}`.trim(),
+ avatarUrl: newRecord.user?.avatar_url,
+ lastSeen: new Date(newRecord.last_seen),
+ };
+
+ if (existingIndex >= 0) {
+ this.onlineUsers[existingIndex] = user;
+ } else {
+ this.onlineUsers.push(user);
+ }
+ } else {
+ this.onlineUsers = this.onlineUsers.filter(u => u.userId !== newRecord?.user_id);
+ }
+ break;
+
+ case 'DELETE':
+ this.onlineUsers = this.onlineUsers.filter(u => u.userId !== oldRecord?.user_id);
+ break;
+ }
+ }
+
+ get otherOnlineUsers(): PresenceUser[] {
+ return this.onlineUsers.filter(u => u.userId !== this.currentUserId);
+ }
+
+ isUserOnline(userId: string): boolean {
+ return this.onlineUsers.some(u => u.userId === userId);
+ }
+
+ dispose() {
+ this.leaveProject();
+ }
+}
diff --git a/apps/web/client/src/components/store/presence/provider.tsx b/apps/web/client/src/components/store/presence/provider.tsx
new file mode 100644
index 0000000000..a15592e5b4
--- /dev/null
+++ b/apps/web/client/src/components/store/presence/provider.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { PresenceManager } from './manager';
+import { useRef, useEffect, createContext, useContext } from 'react';
+
+const PresenceContext = createContext
(null);
+
+export const PresenceProvider = ({ children }: { children: React.ReactNode }) => {
+ const presenceManagerRef = useRef(null);
+
+ useEffect(() => {
+ presenceManagerRef.current = new PresenceManager();
+
+ return () => {
+ presenceManagerRef.current?.dispose();
+ };
+ }, []);
+
+ if (!presenceManagerRef.current) {
+ return null; // Or a loading state
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePresenceManager = () => {
+ const context = useContext(PresenceContext);
+ if (!context) {
+ throw new Error('usePresenceManager must be used within a PresenceProvider');
+ }
+ return context;
+};
diff --git a/apps/web/client/src/server/api/root.ts b/apps/web/client/src/server/api/root.ts
index c6fbcd82a1..e6ffbe94b4 100644
--- a/apps/web/client/src/server/api/root.ts
+++ b/apps/web/client/src/server/api/root.ts
@@ -6,6 +6,7 @@ import {
githubRouter,
invitationRouter,
memberRouter,
+ presenceRouter,
projectRouter,
publishRouter,
sandboxRouter,
@@ -35,6 +36,7 @@ export const appRouter = createTRPCRouter({
userCanvas: userCanvasRouter,
utils: utilsRouter,
member: memberRouter,
+ presence: presenceRouter,
domain: domainRouter,
github: githubRouter,
subscription: subscriptionRouter,
diff --git a/apps/web/client/src/server/api/routers/index.ts b/apps/web/client/src/server/api/routers/index.ts
index a2f62588db..87ba995c2f 100644
--- a/apps/web/client/src/server/api/routers/index.ts
+++ b/apps/web/client/src/server/api/routers/index.ts
@@ -4,6 +4,7 @@ export * from './domain';
export * from './forward';
export * from './github';
export * from './image';
+export * from './presence';
export * from './project';
export * from './publish';
export * from './subscription';
diff --git a/apps/web/client/src/server/api/routers/presence.ts b/apps/web/client/src/server/api/routers/presence.ts
new file mode 100644
index 0000000000..e67f80a2b1
--- /dev/null
+++ b/apps/web/client/src/server/api/routers/presence.ts
@@ -0,0 +1,141 @@
+import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
+import { userPresence, users, userProjects } from '@onlook/db';
+import { and, eq, desc } from 'drizzle-orm';
+import { z } from 'zod';
+
+export const presenceRouter = createTRPCRouter({
+ joinProject: protectedProcedure
+ .input(z.object({ projectId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const { projectId } = input;
+ const userId = ctx.user.id;
+
+ const projectAccess = await ctx.db.query.userProjects.findFirst({
+ where: and(
+ eq(userProjects.userId, userId),
+ eq(userProjects.projectId, projectId)
+ ),
+ });
+
+ if (!projectAccess) {
+ throw new Error('User does not have access to this project');
+ }
+
+ await ctx.db.insert(userPresence).values({
+ projectId,
+ userId,
+ isOnline: true,
+ lastSeen: new Date(),
+ }).onConflictDoUpdate({
+ target: [userPresence.projectId, userPresence.userId],
+ set: {
+ isOnline: true,
+ lastSeen: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+
+ return { success: true };
+ }),
+
+ leaveProject: protectedProcedure
+ .input(z.object({ projectId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const { projectId } = input;
+ const userId = ctx.user.id;
+
+ await ctx.db.update(userPresence)
+ .set({
+ isOnline: false,
+ lastSeen: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(userPresence.projectId, projectId),
+ eq(userPresence.userId, userId)
+ ));
+
+ return { success: true };
+ }),
+
+ getProjectPresence: protectedProcedure
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const { projectId } = input;
+
+ const projectAccess = await ctx.db.query.userProjects.findFirst({
+ where: and(
+ eq(userProjects.userId, ctx.user.id),
+ eq(userProjects.projectId, projectId)
+ ),
+ });
+
+ if (!projectAccess) {
+ throw new Error('User does not have access to this project');
+ }
+
+ const presenceData = await ctx.db.query.userPresence.findMany({
+ where: and(
+ eq(userPresence.projectId, projectId),
+ eq(userPresence.isOnline, true)
+ ),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ displayName: true,
+ avatarUrl: true,
+ firstName: true,
+ lastName: true,
+ },
+ },
+ },
+ orderBy: desc(userPresence.lastSeen),
+ });
+
+ return presenceData.map(p => ({
+ userId: p.user.id,
+ displayName: p.user.displayName || `${p.user.firstName || ''} ${p.user.lastName || ''}`.trim(),
+ avatarUrl: p.user.avatarUrl,
+ lastSeen: p.lastSeen,
+ }));
+ }),
+
+ getMyPresence: protectedProcedure
+ .query(async ({ ctx }) => {
+ const userId = ctx.user.id;
+
+ const presenceData = await ctx.db.query.userPresence.findMany({
+ where: eq(userPresence.userId, userId),
+ with: {
+ project: {
+ columns: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ return presenceData.map(p => ({
+ projectId: p.project.id,
+ projectName: p.project.name,
+ isOnline: p.isOnline,
+ lastSeen: p.lastSeen,
+ }));
+ }),
+
+ cleanupOffline: protectedProcedure
+ .mutation(async ({ ctx }) => {
+ await ctx.db.update(userPresence)
+ .set({
+ isOnline: false,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(userPresence.isOnline, true),
+ ));
+
+ return { success: true };
+ }),
+});
diff --git a/packages/db/src/schema/user/index.ts b/packages/db/src/schema/user/index.ts
index d0095bd877..32d4ad0203 100644
--- a/packages/db/src/schema/user/index.ts
+++ b/packages/db/src/schema/user/index.ts
@@ -1,3 +1,4 @@
+export * from './presence';
export * from './settings';
export * from './user';
export * from './user-canvas';
diff --git a/packages/db/src/schema/user/presence.ts b/packages/db/src/schema/user/presence.ts
new file mode 100644
index 0000000000..ac39052469
--- /dev/null
+++ b/packages/db/src/schema/user/presence.ts
@@ -0,0 +1,38 @@
+import { relations } from 'drizzle-orm';
+import { pgTable, timestamp, uuid, boolean } from 'drizzle-orm/pg-core';
+import { createInsertSchema } from 'drizzle-zod';
+import { projects } from '../project';
+import { users } from './user';
+
+export const userPresence = pgTable('user_presence', {
+ id: uuid('id').primaryKey(),
+ projectId: uuid('project_id')
+ .notNull()
+ .references(() => projects.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
+ userId: uuid('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
+ lastSeen: timestamp('last_seen', { withTimezone: true }).defaultNow().notNull(),
+ isOnline: boolean('is_online').default(true).notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
+}, (table) => ({
+ unique: {
+ projectUser: [table.projectId, table.userId],
+ },
+})).enableRLS();
+
+export const userPresenceRelations = relations(userPresence, ({ one }) => ({
+ project: one(projects, {
+ fields: [userPresence.projectId],
+ references: [projects.id],
+ }),
+ user: one(users, {
+ fields: [userPresence.userId],
+ references: [users.id],
+ }),
+}));
+
+export const userPresenceInsertSchema = createInsertSchema(userPresence);
+export type UserPresence = typeof userPresence.$inferSelect;
+export type NewUserPresence = typeof userPresence.$inferInsert;