diff --git a/README.md b/README.md index d1e16bcab..01da4b077 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ agn up # start the daemon | **Hermes Agent** | ✅ Supported | Nous Hermes CLI with tools, profiles, and memory | | **Cursor** | ✅ Supported | AI code editor | | **OpenCode** | ✅ Supported | Open-source terminal agent | +| **Kimi** | ✅ Supported | Moonshot AI OpenAI-compatible agent | | Aider, Goose, Gemini CLI, Copilot, Amp | 🔜 Coming soon | | --- diff --git a/packages/agent-connector/src/cli.js b/packages/agent-connector/src/cli.js index 7315eb36a..607065d22 100644 --- a/packages/agent-connector/src/cli.js +++ b/packages/agent-connector/src/cli.js @@ -159,6 +159,12 @@ async function cmdRemove(connector, _flags, positional) { async function cmdStart(connector, _flags, positional) { const name = positional[0]; if (!name) { print('Usage: agn start '); return; } + const pid = connector.getDaemonPid(); + if (!pid) { + connector.startDaemon(); + print(`Daemon starting; it will launch configured agents including '${name}'`); + return; + } connector.sendDaemonCommand(`restart:${name}`); print(`Sent start command for '${name}'`); } diff --git a/packages/agent-connector/src/daemon.js b/packages/agent-connector/src/daemon.js index 63209513f..8410e675d 100644 --- a/packages/agent-connector/src/daemon.js +++ b/packages/agent-connector/src/daemon.js @@ -292,7 +292,27 @@ class Daemon { * Read daemon PID, returning null if not running. */ static readDaemonPid(configDir) { - return Daemon._readPid(path.join(configDir, 'daemon.pid')); + const pidFile = path.join(configDir, 'daemon.pid'); + const statusFile = path.join(configDir, 'daemon.status.json'); + const pid = Daemon._readPid(pidFile); + if (!pid) return null; + + // Windows can reuse old PIDs quickly; process.kill(pid, 0) may report + // an unrelated process as alive. The daemon rewrites status every 5s, so + // an old status file is the reliable signal that the PID file is stale. + const now = Date.now(); + const pidAgeMs = Daemon._fileAgeMs(pidFile, now); + const statusAgeMs = Daemon._fileAgeMs(statusFile, now); + const justStarted = pidAgeMs !== null && pidAgeMs < 15000; + const hasFreshStatus = statusAgeMs !== null && statusAgeMs < 20000; + + if (justStarted || hasFreshStatus) { + return pid; + } + + try { fs.unlinkSync(pidFile); } catch {} + try { fs.unlinkSync(statusFile); } catch {} + return null; } // --------------------------------------------------------------------------- @@ -822,6 +842,15 @@ class Daemon { return false; } } + + static _fileAgeMs(file, now = Date.now()) { + try { + if (!fs.existsSync(file)) return null; + return now - fs.statSync(file).mtimeMs; + } catch { + return null; + } + } } module.exports = { Daemon }; diff --git a/packages/agent-connector/test/daemon.test.js b/packages/agent-connector/test/daemon.test.js index 07ef437c4..88cd7c052 100644 --- a/packages/agent-connector/test/daemon.test.js +++ b/packages/agent-connector/test/daemon.test.js @@ -175,12 +175,26 @@ describe('Daemon', () => { assert.equal(Daemon.readDaemonPid(tmpDir), process.pid); }); - it('readDaemonPid returns pid without validating liveness', () => { + it('readDaemonPid keeps a just-written pid while daemon status is pending', () => { fs.writeFileSync(path.join(tmpDir, 'daemon.pid'), '99999999', 'utf-8'); - // PID validation removed — returns raw value (liveness checked elsewhere) assert.equal(Daemon.readDaemonPid(tmpDir), 99999999); }); + it('readDaemonPid clears stale pid and status files', () => { + const pidFile = path.join(tmpDir, 'daemon.pid'); + const statusFile = path.join(tmpDir, 'daemon.status.json'); + fs.writeFileSync(pidFile, String(process.pid), 'utf-8'); + fs.writeFileSync(statusFile, '{"agents":{}}', 'utf-8'); + + const oldDate = new Date(Date.now() - 60000); + fs.utimesSync(pidFile, oldDate, oldDate); + fs.utimesSync(statusFile, oldDate, oldDate); + + assert.equal(Daemon.readDaemonPid(tmpDir), null); + assert.equal(fs.existsSync(pidFile), false); + assert.equal(fs.existsSync(statusFile), false); + }); + it('_reload is serialized (concurrent calls queue)', async () => { const config = new Config(tmpDir); const env = new EnvManager(tmpDir); diff --git a/packages/launcher/src/main/agent-manager.js b/packages/launcher/src/main/agent-manager.js index d8dcb92c0..988c540d1 100644 --- a/packages/launcher/src/main/agent-manager.js +++ b/packages/launcher/src/main/agent-manager.js @@ -345,7 +345,13 @@ class AgentManager { async startAgent(name) { // Ensure daemon is running (long-lived background process) - await this._ensureDaemon(); + const daemonResult = await this._ensureDaemon(); + if (daemonResult && daemonResult.success === false) { + throw new Error(daemonResult.message || 'Failed to start daemon'); + } + if (!this._connector.getDaemonPid()) { + throw new Error('Daemon is not running. Check the Launcher runtime setup and try again.'); + } // Send start command — daemon will launch the agent's adapter this._connector.sendDaemonCommand(`start:${name}`); return { success: true, message: `Start command sent for ${name}` }; @@ -389,7 +395,9 @@ class AgentManager { // Check both unified path (symlink) and legacy platform-specific path const nodeBin = path.join(portableNodeDir, 'node' + (process.platform === 'win32' ? '.exe' : '')); const nodeBinLegacy = path.join(portableNodeDir, 'bin', 'node'); - if (!fs.existsSync(nodeBin) && !fs.existsSync(nodeBinLegacy)) return; + if (!fs.existsSync(nodeBin) && !fs.existsSync(nodeBinLegacy)) { + return { success: false, message: 'Node.js runtime not found. Restart Launcher or reinstall the runtime from Settings.' }; + } return this._startDaemon(); } diff --git a/packages/launcher/src/renderer/renderer.js b/packages/launcher/src/renderer/renderer.js index 699c83675..f93f4e8fe 100644 --- a/packages/launcher/src/renderer/renderer.js +++ b/packages/launcher/src/renderer/renderer.js @@ -432,17 +432,22 @@ async function toggleAgent(name, currentState) { await window.api.startAgent(name); showToast(`Starting ${name}...`, 'info'); // Poll until running (up to 30s — daemon needs time to connect) + let reportedRunning = false; for (let i = 0; i < 10; i++) { await new Promise(r => setTimeout(r, 3000)); const status = await window.api.agentStatus(); const agent = status[name]; if (agent && (agent.state === 'running' || agent.state === 'online')) { showToast(`${name} is now running`, 'success'); + reportedRunning = true; break; } scheduleRefreshDashboard(); scheduleRefreshAgentList(); } + if (!reportedRunning) { + showToast(`${name} did not report as running yet. Check Logs for details.`, 'warning'); + } } } catch (err) { showToast(`Error: ${err.message}`, 'error'); diff --git a/workspace/backend/app/models.py b/workspace/backend/app/models.py index e4c5d09b4..7dd137a30 100644 --- a/workspace/backend/app/models.py +++ b/workspace/backend/app/models.py @@ -55,7 +55,7 @@ class EventRecord(Base): type = Column(Text, nullable=False) # e.g. "workspace.message.posted" source = Column(Text, nullable=False) # e.g. "openagents:claude-agent" target = Column(Text, nullable=False) # e.g. "channel/session-abc" - payload = Column(JSONB) + payload = Column(JSONB) # optional sender fields live here metadata_ = Column("metadata", JSONB, default={}) # underscore to avoid Python keyword timestamp = Column(BigInteger, nullable=False) # unix ms visibility = Column(Text, default="channel") diff --git a/workspace/backend/app/routers/events.py b/workspace/backend/app/routers/events.py index 60fe82230..ec6b9cb74 100644 --- a/workspace/backend/app/routers/events.py +++ b/workspace/backend/app/routers/events.py @@ -41,6 +41,9 @@ class SendEventRequest(BaseModel): metadata: Optional[dict] = None visibility: Optional[str] = "channel" network: Optional[str] = None # workspace ID or slug + sender_id: Optional[str] = None + sender_name: Optional[str] = None + sender_type: Optional[str] = None # --------------------------------------------------------------------------- @@ -79,12 +82,21 @@ async def send_event( if not workspace: return json_response(ResponseCode.NOT_FOUND, "Network not found") + payload = body.payload or {} + if body.type.startswith("workspace.message") and body.source.startswith("human:"): + if body.sender_id and "sender_id" not in payload: + payload["sender_id"] = body.sender_id + if body.sender_name and "sender_name" not in payload: + payload["sender_name"] = body.sender_name + if body.sender_type and "sender_type" not in payload: + payload["sender_type"] = body.sender_type + # Build ONM Event event = Event( type=body.type, source=body.source, target=body.target, - payload=body.payload, + payload=payload, metadata=body.metadata or {}, visibility=body.visibility or "channel", network=str(workspace.id), @@ -140,6 +152,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/components/chat/chat-message.tsx b/workspace/frontend/components/chat/chat-message.tsx index 2d840d4bf..1699c5da8 100644 --- a/workspace/frontend/components/chat/chat-message.tsx +++ b/workspace/frontend/components/chat/chat-message.tsx @@ -27,6 +27,14 @@ function isPreviewable(contentType: string, filename: string): boolean { return false; } +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 Attachments({ items }: { items: Attachment[] }) { if (!items || items.length === 0) return null; @@ -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); @@ -150,15 +159,23 @@ 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 2b45bcf9d..0526b9d7e 100644 --- a/workspace/frontend/components/chat/chat-view.tsx +++ b/workspace/frontend/components/chat/chat-view.tsx @@ -83,7 +83,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 } = useWorkspace(); + const { agents, currentUser, currentSessionId, sessions, updateLastMessage, setSessionActive, agentModes, updateAgentMode, toggleAgentMode, stopAllAgents, activeSessionIds, stoppingSessionIds, renameSession, addParticipant, removeParticipant, consumeSkipFocus } = useWorkspace(); const { isMobile, openMobileList, splitBrowser, showBrowserPreview, setShowBrowserPreview } = useLayout(); // Continuously refresh message caches for top recent sessions in the background. @@ -307,6 +307,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(); @@ -314,8 +315,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: [], @@ -358,9 +360,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 { @@ -369,7 +372,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'); @@ -699,6 +702,7 @@ export function ChatView() { + {/* Online users */} +

+ + Online users ({onlineUsers.length}) +

+
+ {onlineUsers.map((user) => ( +
+
+ {(user.name || 'U').slice(0, 1).toUpperCase()} + +
+ + {user.id === currentUser.id ? `${user.name} (you)` : user.name} + +
+ ))} + {onlineUsers.length === 0 && ( +

No users online

+ )} +
+ {/* Collaboration */}

Collaboration diff --git a/workspace/frontend/components/monitor/monitor-overlay.tsx b/workspace/frontend/components/monitor/monitor-overlay.tsx index 07fde3fa9..4baf43be1 100644 --- a/workspace/frontend/components/monitor/monitor-overlay.tsx +++ b/workspace/frontend/components/monitor/monitor-overlay.tsx @@ -24,7 +24,7 @@ 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 } = useWorkspace(); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(''); const titleInputRef = useRef(null); @@ -107,14 +107,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 +159,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,7 +241,7 @@ export function MonitorOverlay({ sessionId, session, initialMessages, open, onOp {/* Input */}

- +
diff --git a/workspace/frontend/lib/api.ts b/workspace/frontend/lib/api.ts index 998632a78..752cac5ee 100644 --- a/workspace/frontend/lib/api.ts +++ b/workspace/frontend/lib/api.ts @@ -199,13 +199,16 @@ 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_id: senderId || undefined, + sender_name: senderName, sender_type: 'human', ...(mentions && mentions.length > 0 ? { mentions } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), diff --git a/workspace/frontend/lib/types.ts b/workspace/frontend/lib/types.ts index 9fe8a3105..a96013473 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; @@ -55,6 +56,19 @@ export interface WorkspaceCollaborator { addedAt: string | null; } +export interface WorkspaceIdentity { + id: string; + name: string; + isAuthenticated?: boolean; +} + +export interface OnlineUser { + id: string; + name: string; + status: 'online'; + lastSeen?: string; +} + export interface WorkspaceInvitation { invitationId: string; workspaceId: string; @@ -264,12 +278,13 @@ 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, sessionId: event.target.replace(/^channel\//, ''), + senderId: (payload.sender_id as string) || null, senderType: isHuman ? 'human' : 'agent', senderName, content: (payload.content as string) || '', diff --git a/workspace/frontend/lib/workspace-context.tsx b/workspace/frontend/lib/workspace-context.tsx index cdae32130..d08e519b5 100644 --- a/workspace/frontend/lib/workspace-context.tsx +++ b/workspace/frontend/lib/workspace-context.tsx @@ -3,7 +3,58 @@ import React, { createContext, useContext, useCallback, useEffect, useRef, useState } from 'react'; import { workspaceApi } from './api'; import { networkAgentToWorkspaceAgent, networkChannelToSession } from './types'; -import type { BrowserPersistentContext, BrowserTab, DMConversation, RoutineItem, TodoItem, Workspace, WorkspaceAgent, WorkspaceFile, WorkspaceSession } from './types'; +import { useOpenAgentsAuth } from './openagents-auth-context'; +import type { BrowserPersistentContext, BrowserTab, DMConversation, OnlineUser, RoutineItem, TodoItem, Workspace, WorkspaceAgent, WorkspaceFile, WorkspaceIdentity, WorkspaceSession } from './types'; + +const WORKSPACE_USER_ID_KEY = 'workspace_user_id'; +const WORKSPACE_USER_NAME_KEY = 'workspace_user_name'; + +function createWorkspaceUserId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `user-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function useWorkspaceIdentity(): WorkspaceIdentity { + const { user } = useOpenAgentsAuth(); + const [localIdentity, setLocalIdentity] = useState({ + id: '', + name: '', + isAuthenticated: false, + }); + + useEffect(() => { + if (user) return; + try { + let id = localStorage.getItem(WORKSPACE_USER_ID_KEY); + if (!id) { + id = createWorkspaceUserId(); + localStorage.setItem(WORKSPACE_USER_ID_KEY, id); + } + + let name = localStorage.getItem(WORKSPACE_USER_NAME_KEY)?.trim() || ''; + while (!name) { + name = window.prompt('请输入你的名称')?.trim() || ''; + if (!name) break; + } + if (name) localStorage.setItem(WORKSPACE_USER_NAME_KEY, name); + setLocalIdentity({ id, name, isAuthenticated: false }); + } catch { + setLocalIdentity({ id: createWorkspaceUserId(), name: '', isAuthenticated: false }); + } + }, [user]); + + if (user) { + const name = (user.displayName || user.email || '').trim(); + return { + id: user.email || name, + name, + isAuthenticated: true, + }; + } + return localIdentity; +} interface LastMessageInfo { content: string; @@ -15,6 +66,8 @@ interface WorkspaceContextValue { workspace: Workspace | null; token: string; agents: WorkspaceAgent[]; + currentUser: WorkspaceIdentity; + onlineUsers: OnlineUser[]; sessions: WorkspaceSession[]; files: WorkspaceFile[]; selectedFileId: string | null; @@ -94,6 +147,10 @@ export function WorkspaceProvider({ }) { const [workspace, setWorkspace] = useState(null); const [agents, setAgents] = useState([]); + const currentUser = 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 @@ -179,6 +236,33 @@ export function WorkspaceProvider({ try { localStorage.setItem('oa_notification_sound', String(enabled)); } catch {} }, []); + const pruneOnlineUsers = useCallback((users: OnlineUser[]) => { + const cutoff = Date.now() - 45_000; + return users.filter((u) => { + if (u.id === currentUserRef.current.id) return true; + if (!u.lastSeen) return true; + return new Date(u.lastSeen).getTime() >= cutoff; + }); + }, []); + + const upsertOnlineUser = useCallback((user: { id: string; name: string; lastSeen?: string }) => { + if (!user.id || !user.name.trim()) return; + setOnlineUsers((prev) => { + const map = new Map(prev.map((u) => [u.id, u])); + map.set(user.id, { + id: user.id, + name: user.name, + status: 'online', + lastSeen: user.lastSeen || new Date().toISOString(), + }); + return pruneOnlineUsers(Array.from(map.values())).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); + }); + }); + }, [pruneOnlineUsers]); + const updateLastMessage = useCallback((sessionId: string, senderName: string, content: string, isStatus?: boolean) => { if (!isStatus || /stopped|stopping failed/i.test(content)) { setStoppingSessionIds((prev) => { @@ -284,6 +368,91 @@ export function WorkspaceProvider({ workspaceApi.configure(workspaceId, token, bearerToken || undefined); }, [workspaceId, token, bearerToken]); + // Human presence is maintained from workspace.user.* events. The event log is + // the shared source for now; a backend presence projection would be a natural + // follow-up if this needs stronger disconnect semantics. + useEffect(() => { + if (!currentUser.id || !currentUser.name.trim()) return; + + let cancelled = false; + const presencePayload = () => ({ + user_id: currentUser.id, + user_name: currentUser.name, + sender_id: currentUser.id, + sender_name: currentUser.name, + sender_type: 'human', + }); + const sendPresence = (type: 'workspace.user.joined' | 'workspace.user.left' | 'workspace.user.heartbeat') => + workspaceApi.sendEvent({ + type, + source: `human:${currentUser.id}`, + target: 'core', + payload: presencePayload(), + visibility: 'network', + }).catch(() => {}); + + const applyPresenceEvents = async () => { + try { + const result = await workspaceApi.pollEvents({ + type: 'workspace.user', + sort: 'desc', + limit: 200, + }); + if (cancelled) return; + setOnlineUsers((prev) => { + const map = new Map(prev.map((u) => [u.id, u])); + 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 existing = map.get(userId); + const userName = payload.user_name || payload.sender_name || existing?.name || 'User'; + map.set(userId, { + id: userId, + name: userName, + status: 'online', + lastSeen: new Date(event.timestamp).toISOString(), + }); + } + return pruneOnlineUsers(Array.from(map.values())).sort((a, b) => { + if (a.id === currentUser.id) return -1; + if (b.id === currentUser.id) return 1; + return a.name.localeCompare(b.name); + }); + }); + } catch { + setOnlineUsers((prev) => pruneOnlineUsers(prev)); + } + }; + + upsertOnlineUser({ id: currentUser.id, name: currentUser.name }); + 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; + window.clearInterval(heartbeat); + window.removeEventListener('pagehide', handlePageHide); + window.removeEventListener('beforeunload', handlePageHide); + void sendPresence('workspace.user.left'); + }; + }, [currentUser.id, currentUser.name, pruneOnlineUsers, upsertOnlineUser]); + const refreshWorkspace = useCallback(async () => { try { const ws = await workspaceApi.getWorkspace(); @@ -378,7 +547,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'; @@ -637,7 +806,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'; @@ -843,6 +1012,8 @@ export function WorkspaceProvider({ workspace, token, agents, + currentUser, + onlineUsers, sessions, files, selectedFileId,