diff --git a/.gitignore b/.gitignore index 3f4ed8093..f6bd8af96 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ eggs/ lib/ !packages/launcher/src/renderer/lib/ !packages/launcher/src/renderer/lib/** +!workspace/frontend/lib/ +!workspace/frontend/lib/** lib64/ parts/ sdist/ diff --git a/workspace/backend/app/routers/events.py b/workspace/backend/app/routers/events.py index 7e800e274..4c1228a74 100644 --- a/workspace/backend/app/routers/events.py +++ b/workspace/backend/app/routers/events.py @@ -147,6 +147,7 @@ async def send_event( "type": result.type, "source": result.source, "target": result.target, + "payload": result.payload, "timestamp": result.timestamp, "metadata": result.metadata, }) diff --git a/workspace/frontend/app/[workspaceId]/page.tsx b/workspace/frontend/app/[workspaceId]/page.tsx index 78024e43c..a1e2c712b 100644 --- a/workspace/frontend/app/[workspaceId]/page.tsx +++ b/workspace/frontend/app/[workspaceId]/page.tsx @@ -2,10 +2,22 @@ import { use, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; -import { WorkspaceProvider } from '@/lib/workspace-context'; +import { WorkspaceProvider, useWorkspace } from '@/lib/workspace-context'; import { LayoutProvider } from '@/components/layout/layout-context'; import { Wrapper } from '@/components/layout/wrapper'; import { useOpenAgentsAuth } from '@/lib/openagents-auth-context'; +import { IdentityDialog } from '@/components/identity/identity-dialog'; + +function IdentityGate({ children }: { children: React.ReactNode }) { + const { currentUser, setUserName } = useWorkspace(); + + return ( + <> + + {children} + + ); +} function WorkspaceContent({ workspaceId }: { workspaceId: string }) { const searchParams = useSearchParams(); @@ -16,9 +28,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) { if (token) { return ( - - - + + + + + ); } @@ -37,9 +51,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) { // User is logged in — try to access workspace via bearer token return ( - - - + + + + + ); } diff --git a/workspace/frontend/components/chat/chat-message.tsx b/workspace/frontend/components/chat/chat-message.tsx index 67049e68f..bdb16ca7a 100644 --- a/workspace/frontend/components/chat/chat-message.tsx +++ b/workspace/frontend/components/chat/chat-message.tsx @@ -19,6 +19,14 @@ interface Attachment { url: string; } +function humanColor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return `hsl(${hash % 360} 55% 82%)`; +} + function isPreviewable(contentType: string, filename: string): boolean { if (contentType?.startsWith('image/')) return true; if (contentType === 'text/html' || /\.html?$/i.test(filename)) return true; @@ -108,7 +116,8 @@ interface ChatMessageProps { } export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: ChatMessageProps) { - const isHuman = message.senderType === 'human'; + const { currentUser } = useWorkspace(); + const isHuman = message.senderType === 'human' || message.senderType === 'user'; const isSystem = message.messageType === 'status'; const [copied, setCopied] = useState(false); @@ -149,15 +158,24 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C // ── Human message — Slack style ── if (isHuman) { + const isCurrentUser = !!message.senderId && message.senderId === currentUser.id; + const displayName = isCurrentUser + ? 'You' + : (message.senderName && message.senderName !== 'user' ? message.senderName : 'User'); + const seed = message.senderId || message.senderName || 'human'; + return (
-
- +
+
- You + {displayName} {timestamp && ( {timestamp} )} diff --git a/workspace/frontend/components/chat/chat-view.tsx b/workspace/frontend/components/chat/chat-view.tsx index c9053464d..c5fe9cb10 100644 --- a/workspace/frontend/components/chat/chat-view.tsx +++ b/workspace/frontend/components/chat/chat-view.tsx @@ -85,7 +85,7 @@ async function refreshCachedSession(sessionId: string): Promise { } export function ChatView() { - const { agents, currentSessionId, sessions, updateLastMessage, setSessionActive, agentModes, updateAgentMode, toggleAgentMode, stopAllAgents, activeSessionIds, stoppingSessionIds, renameSession, addParticipant, removeParticipant, consumeSkipFocus, createRoutine } = useWorkspace(); + const { agents, currentUser, currentSessionId, sessions, updateLastMessage, setSessionActive, agentModes, updateAgentMode, toggleAgentMode, stopAllAgents, activeSessionIds, stoppingSessionIds, renameSession, addParticipant, removeParticipant, consumeSkipFocus, createRoutine } = useWorkspace(); const [showCreateRoutine, setShowCreateRoutine] = useState(false); const { isMobile, @@ -317,6 +317,7 @@ export function ChatView() { const handleSend = useCallback( async (content: string, mentions: string[] = [], files: PendingFile[] = []) => { if (!currentSessionId) return; + if (!currentUser.id || !currentUser.name.trim()) return; // Create optimistic messages for instant feedback const timestamp = Date.now(); @@ -324,8 +325,9 @@ export function ChatView() { const userOptimisticMsg: WorkspaceMessage = { messageId: `optimistic-user-${timestamp}`, sessionId: currentSessionId, - senderName: 'You', - senderType: 'user', + senderId: currentUser.id, + senderName: currentUser.name, + senderType: 'human', content: userContent, messageType: 'chat', mentions: [], @@ -368,9 +370,10 @@ export function ChatView() { await workspaceApi.sendMessage( currentSessionId, content || (attachments ? attachments.map((a) => a.filename).join(', ') : ''), - 'user', + currentUser.name, mentions.length > 0 ? mentions : undefined, attachments, + currentUser.id, ); forceRefresh(); } catch { @@ -379,7 +382,7 @@ export function ChatView() { setOptimisticMessages([]); } }, - [currentSessionId, forceRefresh, agents] + [currentSessionId, currentUser.id, currentUser.name, forceRefresh, agents] ); const hasStatusMessages = displayMessages.some((m) => m.messageType === 'status' || m.messageType === 'thinking'); @@ -686,6 +689,7 @@ export function ChatView() { onFocusChange={(focused) => focused ? notifyFocus() : notifyBlur()} focusKey={focusKey} onCreateRoutine={() => setShowCreateRoutine(true)} + disabled={!currentUser.name.trim()} />
diff --git a/workspace/frontend/components/identity/identity-dialog.tsx b/workspace/frontend/components/identity/identity-dialog.tsx new file mode 100644 index 000000000..c97116cbf --- /dev/null +++ b/workspace/frontend/components/identity/identity-dialog.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import { User } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +interface IdentityDialogProps { + open: boolean; + onSubmit: (name: string) => void; +} + +export function IdentityDialog({ open, onSubmit }: IdentityDialogProps) { + const [name, setName] = useState(''); + + const handleSubmit = () => { + const trimmed = name.trim(); + if (!trimmed) return; + onSubmit(trimmed); + }; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + className="sm:max-w-sm" + > + +
+
+ +
+ Join Workspace +
+ + Enter your name so others can see who you are in the chat. + +
+
{ + e.preventDefault(); + handleSubmit(); + }} + className="space-y-4" + > + setName(e.target.value)} + placeholder="Your name" + autoFocus + maxLength={50} + /> + +
+
+
+ ); +} diff --git a/workspace/frontend/components/layout/sidebar-content.tsx b/workspace/frontend/components/layout/sidebar-content.tsx index 77cf25539..8934b804f 100644 --- a/workspace/frontend/components/layout/sidebar-content.tsx +++ b/workspace/frontend/components/layout/sidebar-content.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { Plus, MessageSquare, FileText, Globe, PlusSquare, Settings, Copy, Check, ListTodo, CalendarClock, - LogIn, LogOut, Shield, Moon, Sun, KeyRound, Share2, X, Crown, + LogIn, LogOut, Shield, Moon, Sun, KeyRound, Share2, X, Crown, Users, } from 'lucide-react'; import { useTheme } from 'next-themes'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -71,7 +71,7 @@ function NavButton({ export function SidebarContent() { const { isSidebarOpen, sidebarToggle, viewMode, setViewMode, setSelectedAgentName } = useLayout(); - const { agents, sessions, files, browserTabs, createSession, workspace, token, refreshWorkspace, todos, routines } = useWorkspace(); + const { agents, sessions, files, browserTabs, createSession, workspace, token, refreshWorkspace, todos, routines, currentUser, onlineUsers } = useWorkspace(); const { user, isOpenAgentsDomain, signIn, signOut } = useOpenAgentsAuth(); const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); @@ -241,6 +241,29 @@ export function SidebarContent() { ))}
+ {/* Online Users */} + {onlineUsers.length > 0 && ( + <> +

+ + Online ({onlineUsers.length}) +

+
+ {onlineUsers.map((u) => ( +
+
+ + {u.id === currentUser.id ? `${u.name} (you)` : u.name} + +
+ ))} +
+ + )} + {/* Collaboration */}

Collaboration @@ -575,4 +598,3 @@ function SettingsDialogPortal({ open, onOpenChange, workspace, refreshWorkspace ); } - diff --git a/workspace/frontend/components/monitor/monitor-overlay.tsx b/workspace/frontend/components/monitor/monitor-overlay.tsx index 07fde3fa9..6ebdbc074 100644 --- a/workspace/frontend/components/monitor/monitor-overlay.tsx +++ b/workspace/frontend/components/monitor/monitor-overlay.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dialog'; import { ChatMessages } from '@/components/chat/chat-messages'; import { ChatInput, type PendingFile } from '@/components/chat/chat-input'; +import { CreateRoutineDialog } from '@/components/routines/create-routine-dialog'; import { useWorkspace } from '@/lib/workspace-context'; import { useMessagePolling } from '@/hooks/use-polling'; import { workspaceApi } from '@/lib/api'; @@ -24,7 +25,8 @@ interface MonitorOverlayProps { } export function MonitorOverlay({ sessionId, session, initialMessages, open, onOpenChange }: MonitorOverlayProps) { - const { agents, activeSessionIds, stoppingSessionIds, stopAllAgents, renameSession } = useWorkspace(); + const { agents, currentUser, activeSessionIds, stoppingSessionIds, stopAllAgents, renameSession, createRoutine } = useWorkspace(); + const [showCreateRoutine, setShowCreateRoutine] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(''); const titleInputRef = useRef(null); @@ -107,14 +109,17 @@ export function MonitorOverlay({ sessionId, session, initialMessages, open, onOp const handleSend = useCallback( async (content: string, mentions: string[] = [], files: PendingFile[] = []) => { + if (!currentUser.id || !currentUser.name.trim()) return; + // Optimistic messages const timestamp = Date.now(); const userContent = content || (files.length > 0 ? files.map((f) => f.file.name).join(', ') : ''); const userOptimisticMsg: WorkspaceMessage = { messageId: `optimistic-user-${timestamp}`, sessionId, - senderName: 'You', - senderType: 'user', + senderId: currentUser.id, + senderName: currentUser.name, + senderType: 'human', content: userContent, messageType: 'chat', mentions: [], @@ -156,16 +161,17 @@ export function MonitorOverlay({ sessionId, session, initialMessages, open, onOp await workspaceApi.sendMessage( sessionId, content || (attachments ? attachments.map((a) => a.filename).join(', ') : ''), - 'user', + currentUser.name, mentions.length > 0 ? mentions : undefined, attachments, + currentUser.id, ); forceRefresh(); } catch { setOptimisticMessages([]); } }, - [sessionId, forceRefresh, agents] + [sessionId, currentUser.id, currentUser.name, forceRefresh, agents] ); return ( @@ -237,11 +243,18 @@ export function MonitorOverlay({ sessionId, session, initialMessages, open, onOp {/* Input */}

- + setShowCreateRoutine(true)} />
+ + ); } diff --git a/workspace/frontend/lib/api.ts b/workspace/frontend/lib/api.ts index d7946699a..ff2d7f64b 100644 --- a/workspace/frontend/lib/api.ts +++ b/workspace/frontend/lib/api.ts @@ -199,14 +199,17 @@ class WorkspaceApi { senderName = 'user', mentions?: string[], attachments?: { fileId: string; filename: string; contentType: string; url: string }[], + senderId?: string, ): Promise { return this.sendEvent({ type: 'workspace.message.posted', - source: `human:${senderName}`, + source: `human:${senderId || senderName}`, target: `channel/${channelName}`, payload: { content, sender_type: 'human', + ...(senderId ? { sender_id: senderId } : {}), + sender_name: senderName, ...(mentions && mentions.length > 0 ? { mentions } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), }, diff --git a/workspace/frontend/lib/identity.ts b/workspace/frontend/lib/identity.ts new file mode 100644 index 000000000..744d140ad --- /dev/null +++ b/workspace/frontend/lib/identity.ts @@ -0,0 +1,39 @@ +const USER_ID_COOKIE = 'oa_user_id'; +const USER_NAME_COOKIE = 'oa_user_name'; +const MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds + +function setCookie(name: string, value: string) { + document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${MAX_AGE};SameSite=Lax`; +} + +function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +export function generateUserId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `user-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function getStoredIdentity(): { id: string; name: string } | null { + try { + const id = getCookie(USER_ID_COOKIE); + const name = getCookie(USER_NAME_COOKIE); + if (id && name) return { id, name }; + } catch { + // SSR or cookie access blocked + } + return null; +} + +export function storeIdentity(id: string, name: string) { + try { + setCookie(USER_ID_COOKIE, id); + setCookie(USER_NAME_COOKIE, name); + } catch { + // cookie access blocked + } +} diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index 02bb2b1f5..4627fe92a 100644 --- a/workspace/frontend/lib/types.ts +++ b/workspace/frontend/lib/types.ts @@ -38,6 +38,7 @@ export interface WorkspaceSession { export interface WorkspaceMessage { messageId: string; sessionId: string; + senderId?: string | null; senderType: string; senderName: string; content: string; @@ -48,6 +49,19 @@ export interface WorkspaceMessage { createdAt: string | null; } +export interface WorkspaceIdentity { + id: string; + name: string; + isAuthenticated: boolean; +} + +export interface OnlineUser { + id: string; + name: string; + status: 'online'; + lastSeen: number; +} + export interface WorkspaceCollaborator { email: string; role: 'editor' | 'viewer'; @@ -266,11 +280,12 @@ export interface DMConversation { /** Convert an ONM event to a WorkspaceMessage for the chat UI. */ export function eventToMessage(event: ONMEvent): WorkspaceMessage { const isHuman = event.source.startsWith('human:'); - const senderName = event.source.replace(/^(openagents:|human:)/, ''); const payload = (event.payload || {}) as Record; + const senderName = (payload.sender_name as string) || event.source.replace(/^(openagents:|human:)/, ''); return { messageId: event.id, + senderId: (payload.sender_id as string) || null, sessionId: event.target.replace(/^channel\//, ''), senderType: isHuman ? 'human' : 'agent', senderName, diff --git a/workspace/frontend/lib/workspace-context.tsx b/workspace/frontend/lib/workspace-context.tsx index ced33d971..5035df59e 100644 --- a/workspace/frontend/lib/workspace-context.tsx +++ b/workspace/frontend/lib/workspace-context.tsx @@ -2,8 +2,43 @@ import React, { createContext, useContext, useCallback, useEffect, useRef, useState } from 'react'; import { workspaceApi } from './api'; +import { useOpenAgentsAuth } from './openagents-auth-context'; +import { generateUserId, getStoredIdentity, storeIdentity } from './identity'; import { networkAgentToWorkspaceAgent, networkChannelToSession } from './types'; -import type { BrowserPersistentContext, BrowserTab, DMConversation, RoutineItem, TodoItem, Workspace, WorkspaceAgent, WorkspaceFile, WorkspaceSession } from './types'; +import type { BrowserPersistentContext, BrowserTab, DMConversation, OnlineUser, RoutineItem, TodoItem, Workspace, WorkspaceAgent, WorkspaceFile, WorkspaceIdentity, WorkspaceSession } from './types'; + +function useWorkspaceIdentity() { + const { user } = useOpenAgentsAuth(); + const [localIdentity, setLocalIdentity] = useState(() => { + const stored = typeof window !== 'undefined' ? getStoredIdentity() : null; + const id = stored?.id || (typeof window !== 'undefined' ? generateUserId() : ''); + return { id, name: stored?.name || '', isAuthenticated: false }; + }); + + useEffect(() => { + if (!user && localIdentity.id && localIdentity.name) { + storeIdentity(localIdentity.id, localIdentity.name); + } + }, [user, localIdentity.id, localIdentity.name]); + + const setUserName = useCallback((name: string) => { + setLocalIdentity((prev) => { + const id = prev.id || generateUserId(); + storeIdentity(id, name); + return { id, name, isAuthenticated: false }; + }); + }, []); + + if (user) { + const name = (user.displayName || user.email || '').trim(); + return { + currentUser: { id: user.email || name, name, isAuthenticated: true } as WorkspaceIdentity, + setUserName: () => {}, + }; + } + + return { currentUser: localIdentity, setUserName }; +} interface LastMessageInfo { content: string; @@ -15,6 +50,9 @@ interface WorkspaceContextValue { workspace: Workspace | null; token: string; agents: WorkspaceAgent[]; + currentUser: WorkspaceIdentity; + setUserName: (name: string) => void; + onlineUsers: OnlineUser[]; sessions: WorkspaceSession[]; files: WorkspaceFile[]; selectedFileId: string | null; @@ -104,6 +142,10 @@ export function WorkspaceProvider({ }) { const [workspace, setWorkspace] = useState(null); const [agents, setAgents] = useState([]); + const { currentUser, setUserName } = useWorkspaceIdentity(); + const currentUserRef = useRef(currentUser); + currentUserRef.current = currentUser; + const [onlineUsers, setOnlineUsers] = useState([]); const [sessions, setSessions] = useState([]); const [currentSessionId, _setCurrentSessionId] = useState(null); // Set by setCurrentSessionId({ skipFocus: true }) and consumed by ChatView's @@ -189,6 +231,71 @@ export function WorkspaceProvider({ try { localStorage.setItem('oa_notification_sound', String(enabled)); } catch {} }, []); + // Presence heartbeat + useEffect(() => { + if (!currentUser.id || !currentUser.name.trim()) return; + + let cancelled = false; + const sendPresence = (type: string) => + workspaceApi.sendEvent({ + type, + source: `human:${currentUser.id}`, + target: 'core', + payload: { user_id: currentUser.id, user_name: currentUser.name, sender_type: 'human' }, + visibility: 'network', + }).catch(() => {}); + + const applyPresenceEvents = async () => { + try { + const result = await workspaceApi.pollEvents({ type: 'workspace.user', sort: 'desc', limit: 200 }); + if (cancelled) return; + const cutoff = Date.now() - 45_000; + const map = new Map(); + for (const event of [...result.events].reverse()) { + const payload = (event.payload || {}) as Record; + const userId = payload.user_id || payload.sender_id; + if (!userId) continue; + if (event.type === 'workspace.user.left') { + map.delete(userId); + continue; + } + const userName = payload.user_name || payload.sender_name || 'User'; + map.set(userId, { id: userId, name: userName, status: 'online', lastSeen: event.timestamp }); + } + const users = Array.from(map.values()) + .filter((u) => u.id === currentUserRef.current.id || u.lastSeen >= cutoff) + .sort((a, b) => { + if (a.id === currentUserRef.current.id) return -1; + if (b.id === currentUserRef.current.id) return 1; + return a.name.localeCompare(b.name); + }); + setOnlineUsers(users); + } catch { + // non-critical + } + }; + + void sendPresence('workspace.user.joined'); + void applyPresenceEvents(); + + const heartbeat = window.setInterval(() => { + void sendPresence('workspace.user.heartbeat'); + void applyPresenceEvents(); + }, 15_000); + + const handlePageHide = () => void sendPresence('workspace.user.left'); + window.addEventListener('pagehide', handlePageHide); + window.addEventListener('beforeunload', handlePageHide); + + return () => { + cancelled = true; + clearInterval(heartbeat); + window.removeEventListener('pagehide', handlePageHide); + window.removeEventListener('beforeunload', handlePageHide); + void sendPresence('workspace.user.left'); + }; + }, [currentUser.id, currentUser.name]); + const updateLastMessage = useCallback((sessionId: string, senderName: string, content: string, isStatus?: boolean) => { if (!isStatus || /stopped|stopping failed/i.test(content)) { setStoppingSessionIds((prev) => { @@ -388,7 +495,7 @@ export function WorkspaceProvider({ // If agent is actively working, show the status; otherwise show last chat const pick = isAgentWorking ? latest : (lastChat || latest); const payload = pick.payload as Record; - const sender = pick.source.replace(/^(openagents:|human:)/, ''); + const sender = payload?.sender_name || pick.source.replace(/^(openagents:|human:)/, ''); const content = payload?.content || ''; const msgType = payload?.message_type || 'chat'; const isStatus = msgType === 'status' || msgType === 'thinking'; @@ -666,7 +773,7 @@ export function WorkspaceProvider({ const batch: Record = {}; for (const [channelName, event] of Object.entries(bulk.channels)) { const payload = event.payload as Record; - const sender = event.source.replace(/^(openagents:|human:)/, ''); + const sender = payload?.sender_name || event.source.replace(/^(openagents:|human:)/, ''); const content = payload?.content || ''; const msgType = payload?.message_type || 'chat'; const isStatus = msgType === 'status' || msgType === 'thinking'; @@ -872,6 +979,9 @@ export function WorkspaceProvider({ workspace, token, agents, + currentUser, + setUserName, + onlineUsers, sessions, files, selectedFileId,