diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 8da1c90..a9e6b6e 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -24,7 +24,9 @@ import { type MemoryDismissPayload, type MemoryScope, type MemorySuggestionPayload, + type ParticipantsPayload, PI_AGENT, + type SessionParticipant, type SessionStatusPayload, type SessionStatusSnapshotPayload, SocketEvents, @@ -59,6 +61,121 @@ function roomFor(sessionId: string): string { return `session:${sessionId}`; } +/** A user currently connected to a session, keyed by their author id (email). */ +interface ActiveUser { + id: string; + name: string; +} + +/** + * Tracks which humans are currently connected to each session's room. A user + * may have several sockets open (multiple tabs), so presence is ref-counted per + * author id and only drops once the last of their sockets leaves. + */ +class SessionPresence { + private readonly bySession = new Map< + string, + Map + >(); + private readonly bySocket = new Map< + string, + { sessionId: string; id: string } + >(); + + /** Records a socket as present for a session under the given human identity. */ + join(socketId: string, sessionId: string, author: ChatAuthor): void { + if (author.kind !== "human") return; + this.bySocket.set(socketId, { sessionId, id: author.id }); + const users = this.bySession.get(sessionId) ?? new Map(); + const existing = users.get(author.id); + users.set(author.id, { + name: author.name, + count: (existing?.count ?? 0) + 1, + }); + this.bySession.set(sessionId, users); + } + + /** Drops a socket's presence, returning the session it affected (if any). */ + leave(socketId: string): string | null { + const entry = this.bySocket.get(socketId); + if (!entry) return null; + this.bySocket.delete(socketId); + const users = this.bySession.get(entry.sessionId); + const existing = users?.get(entry.id); + if (!users || !existing) return entry.sessionId; + if (existing.count <= 1) { + users.delete(entry.id); + } else { + users.set(entry.id, { name: existing.name, count: existing.count - 1 }); + } + return entry.sessionId; + } + + /** The distinct humans currently connected to a session. */ + activeUsers(sessionId: string): ActiveUser[] { + const users = this.bySession.get(sessionId); + if (!users) return []; + return Array.from(users, ([id, { name }]) => ({ id, name })); + } +} + +/** + * Builds the session's participant roster: every human who authored a message + * (inactive by default) merged with the currently-connected users (active, + * whose name wins). Active participants sort first, then alphabetically. + */ +function buildParticipants( + messages: ChatMessage[], + active: ActiveUser[], +): SessionParticipant[] { + const byId = new Map(); + for (const { author } of messages) { + if (author.kind !== "human" || byId.has(author.id)) continue; + byId.set(author.id, { id: author.id, name: author.name, active: false }); + } + for (const user of active) { + byId.set(user.id, { id: user.id, name: user.name, active: true }); + } + return Array.from(byId.values()).sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + return a.name.localeCompare(b.name); + }); +} + +/** Emits the session's current participant roster to everyone in its room. */ +async function emitParticipants( + io: Server, + store: SessionStore, + presence: SessionPresence, + sessionId: string, +): Promise { + const messages = await store.getMessages(sessionId); + const payload: ParticipantsPayload = { + sessionId, + participants: buildParticipants(messages, presence.activeUsers(sessionId)), + }; + io.to(roomFor(sessionId)).emit(SocketEvents.Participants, payload); +} + +/** + * Emits the participant roster on join to both the joining socket and the rest + * of the room, reusing the message history already read during the join. + */ +function emitJoinParticipants( + socket: Socket, + room: string, + presence: SessionPresence, + sessionId: string, + history: ChatMessage[], +): void { + const payload: ParticipantsPayload = { + sessionId, + participants: buildParticipants(history, presence.activeUsers(sessionId)), + }; + socket.emit(SocketEvents.Participants, payload); + socket.to(room).emit(SocketEvents.Participants, payload); +} + /** * Shared room every client viewing a session list (the switcher, the sessions * table) joins to receive live run-status updates for all sessions at once, @@ -515,11 +632,16 @@ export function registerChatHandlers( triggerEngine: TriggerEngine, emitUiCommand: UiCommandEmitter, ): void { + const presence = new SessionPresence(); + io.on("connection", (socket: Socket) => { socket.on(SocketEvents.ChatJoin, (payload: ChatJoinPayload) => - handleChatJoin(socket, store, pi, triggerEngine, payload), + handleChatJoin(socket, store, pi, triggerEngine, presence, payload), ); + socket.on("disconnect", () => + handleDisconnect(io, store, presence, socket), + ); socket.on(SocketEvents.ChatMessage, (payload: ChatMessagePayload) => handleChatMessage(io, socket, store, pi, payload), ); @@ -562,6 +684,17 @@ export function registerChatHandlers( }); } +/** Drops a socket's presence on disconnect and refreshes its session's roster. */ +function handleDisconnect( + io: Server, + store: SessionStore, + presence: SessionPresence, + socket: Socket, +): void { + const sessionId = presence.leave(socket.id); + if (sessionId) void emitParticipants(io, store, presence, sessionId); +} + /** Reads Prime's persisted model/thinking selection, parsing the stored depth. */ async function loadPrimeOverride( store: SessionStore, @@ -633,6 +766,7 @@ async function handleChatJoin( store: SessionStore, pi: PiAgentManager, triggerEngine: TriggerEngine, + presence: SessionPresence, payload: ChatJoinPayload, ): Promise { const session = await store.getSession(payload?.sessionId); @@ -644,6 +778,10 @@ async function handleChatJoin( const room = roomFor(session.id); await socket.join(room); + // Record this socket's live presence so the participant bar shows the user as + // active, then broadcast the refreshed roster to everyone in the room. + if (payload.author) presence.join(socket.id, session.id, payload.author); + // Lazily (re)spawn the agent in case the server restarted or the session was // created before the process manager existed, restoring any persisted Prime // model/thinking selection so a respawn keeps the human's prior choice. @@ -668,25 +806,56 @@ async function handleChatJoin( const history = await store.getMessages(session.id); socket.emit(SocketEvents.ChatHistory, history); + await replayJoinSnapshot( + socket, + store, + pi, + triggerEngine, + presence, + session.id, + history, + ); +} + +/** + * Replays a session's current state to a freshly-joined socket: the participant + * roster (broadcast to the whole room), the sub-agent roster, each live agent's + * run-level activity, Prime's model/thinking selection, the trigger roster, and + * the pinned artifacts. + */ +async function replayJoinSnapshot( + socket: Socket, + store: SessionStore, + pi: PiAgentManager, + triggerEngine: TriggerEngine, + presence: SessionPresence, + sessionId: string, + history: ChatMessage[], +): Promise { + emitJoinParticipants( + socket, + roomFor(sessionId), + presence, + sessionId, + history, + ); + const roster: SubagentRosterPayload = { - sessionId: session.id, - subagents: pi.listSubagents(session.id), + sessionId, + subagents: pi.listSubagents(sessionId), }; socket.emit(SocketEvents.SubagentRoster, roster); - // Replay each live agent's current run-level activity for the joining client. - replayAgentActivities(socket, pi, session.id); - - // Surface Prime's current model/thinking (the roster only tracks sub-agents). - emitPrimeSelection(socket, pi, session.id); + replayAgentActivities(socket, pi, sessionId); + emitPrimeSelection(socket, pi, sessionId); const triggerRoster: TriggerRosterPayload = { - sessionId: session.id, - triggers: triggerEngine.list(session.id), + sessionId, + triggers: triggerEngine.list(sessionId), }; socket.emit(SocketEvents.TriggerRoster, triggerRoster); - await replayArtifacts(socket, store, session.id); + await replayArtifacts(socket, store, sessionId); } /** diff --git a/apps/web/src/features/chat/components/PrimeChatPanel.tsx b/apps/web/src/features/chat/components/PrimeChatPanel.tsx index d01036f..94c59b1 100644 --- a/apps/web/src/features/chat/components/PrimeChatPanel.tsx +++ b/apps/web/src/features/chat/components/PrimeChatPanel.tsx @@ -4,6 +4,7 @@ import { type MemorySuggestionPayload, type MessageDelivery, PI_AGENT, + type SessionParticipant, type SubagentInfo, type Trigger, } from "@tangent/shared/contracts"; @@ -20,6 +21,7 @@ import { BundlePanelLauncher } from "./composer/BundlePanelLauncher"; import { ChatInput } from "./composer/ChatInput"; import { MemorySuggestionCard } from "./composer/MemorySuggestionCard"; import { ChatMessageList } from "./message/ChatMessageList"; +import { SessionParticipants } from "./SessionParticipants"; type SendFn = ( content: string, @@ -33,6 +35,7 @@ type SendFn = ( interface PrimeChatPanelProps { sessionId: string; messages: ChatMessage[]; + participants: SessionParticipant[]; currentAuthorId: string; bundleId?: string; connected: boolean; @@ -61,6 +64,7 @@ interface PrimeChatPanelProps { export function PrimeChatPanel({ sessionId, messages, + participants, currentAuthorId, bundleId, connected, @@ -87,6 +91,11 @@ export function PrimeChatPanel({ }: PrimeChatPanelProps) { return ( + {participants.length > 0 && ( + + + + )} "JS". */ +function initialsFromName(name: string): string { + const letters = name + .trim() + .split(/\s+/) + .map((part) => part.charAt(0)) + .join(""); + return letters.slice(0, 2).toUpperCase() || "?"; +} + +/** + * SessionParticipants — a row of slightly overlapping avatars for the humans in + * a session. Users currently connected over the socket render in color; users + * who only authored a message (and are not connected) render grayscale. + * + * Styles raw `
`/`` (the sanctioned escape hatch, like `StatusDot`), + * so it is exempt from `tangle-ui/no-classname-on-primitives`. + */ +export function SessionParticipants({ + participants, +}: SessionParticipantsProps) { + if (participants.length === 0) return null; + + return ( + // local primitive +
+ {participants.map((participant) => { + const title = participant.active + ? `${participant.name} (active)` + : participant.name; + const fallback = ( + // local primitive +
+ {initialsFromName(participant.name)} +
+ ); + + return ( + // local primitive + + + + ); + })} +
+ ); +} diff --git a/apps/web/src/features/chat/components/message/ChatMessage.tsx b/apps/web/src/features/chat/components/message/ChatMessage.tsx index 4e8820b..0b410f4 100644 --- a/apps/web/src/features/chat/components/message/ChatMessage.tsx +++ b/apps/web/src/features/chat/components/message/ChatMessage.tsx @@ -91,7 +91,9 @@ function ChatMessageContent({ kind={message.author.kind} name={message.author.name} agentRole={message.author.agentRole} - email={message.author.kind === "human" ? message.author.id : undefined} + email={ + message.author.kind === "human" ? message.author.id : undefined + } /> } header={ diff --git a/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx b/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx index dc43e75..5662f3e 100644 --- a/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx +++ b/apps/web/src/features/chat/components/message/ThinkingOnlyMessage.tsx @@ -38,7 +38,6 @@ export function ThinkingOnlyMessage({ kind={message.author.kind} name={message.author.name} agentRole={message.author.agentRole} - email={message.author.kind === "human" ? message.author.id : undefined} /> } header={ diff --git a/apps/web/src/features/chat/hooks/useSessionChat.ts b/apps/web/src/features/chat/hooks/useSessionChat.ts index 8829e7a..08995c6 100644 --- a/apps/web/src/features/chat/hooks/useSessionChat.ts +++ b/apps/web/src/features/chat/hooks/useSessionChat.ts @@ -19,9 +19,11 @@ import { type MemoryDismissPayload, type MemorySuggestionPayload, type MessageDelivery, + type ParticipantsPayload, PI_AGENT, type PinnedArtifact, type Session, + type SessionParticipant, SocketEvents, type SubagentInfo, type SubagentRosterPayload, @@ -87,6 +89,9 @@ export interface AgentModelSelection { export function useSessionChat(sessionId: string) { const [messages, setMessages] = useState([]); const [subagents, setSubagents] = useState([]); + // Humans taking part in this session: message authors plus currently-connected + // users, kept in sync with the room via the `session:participants` event. + const [participants, setParticipants] = useState([]); // Per-agent model/thinking selection, keyed by agent id (`"prime"` or a // sub-agent id). Seeded from the roster (sub-agents) and the `agent:model` // event (Prime), and updated as either changes. @@ -131,6 +136,12 @@ export function useSessionChat(sessionId: string) { kind: "human", name: userShortName(user), }; + // Mirror the author in a ref so the `sessionId`-keyed connection effect can + // send an up-to-date identity on join without re-subscribing when it changes. + const authorRef = useRef(author); + useEffect(() => { + authorRef.current = author; + }); useEffect(() => { if (!sessionId) return; @@ -181,6 +192,7 @@ export function useSessionChat(sessionId: string) { setMessages([]); setHistoryLoaded(false); setSubagents([]); + setParticipants([]); setModelByAgent(new Map()); setTriggers([]); setArtifacts([]); @@ -196,11 +208,15 @@ export function useSessionChat(sessionId: string) { activities.clear(); statuses.clear(); publish(PI_AGENT.id); - socket.emit(SocketEvents.ChatJoin, { sessionId }); + socket.emit(SocketEvents.ChatJoin, { + sessionId, + author: authorRef.current, + }); }); socket.on("disconnect", () => { setConnected(false); setArtifacts([]); + setParticipants([]); setStreamingConversations(new Set()); setStreamingMessageIds(new Set()); setActivityByConversation(new Map()); @@ -221,6 +237,15 @@ export function useSessionChat(sessionId: string) { setMessages((prev) => [...prev, message]); }); + // Participant roster (sent on join and whenever presence changes): replace + // local state so the avatar bar reflects who is active vs. inactive. + socket.on( + SocketEvents.Participants, + ({ participants: roster }: ParticipantsPayload) => { + setParticipants(roster); + }, + ); + // An agent begins a (new) message: append an empty placeholder we fill via // deltas and mark that conversation's message stream in flight. socket.on(SocketEvents.AgentStart, ({ message }: AgentStartPayload) => { @@ -570,6 +595,7 @@ export function useSessionChat(sessionId: string) { return { messages, subagents, + participants, triggers, artifacts, pinnedPaths, diff --git a/apps/web/src/features/user/components/UserAvatar.tsx b/apps/web/src/features/user/components/UserAvatar.tsx index e3be1df..8f58b6d 100644 --- a/apps/web/src/features/user/components/UserAvatar.tsx +++ b/apps/web/src/features/user/components/UserAvatar.tsx @@ -1,10 +1,10 @@ +import { Spinner } from "@tangent/ui-primitives/spinner"; import { useQuery } from "@tanstack/react-query"; import { cva } from "class-variance-authority"; import type { ReactNode } from "react"; import { resolveGravatarUrl } from "@/features/user/model/gravatar"; import { UserQueryKeys } from "@/features/user/model/userQueryKeys"; -import { Spinner } from "@tangent/ui-primitives/spinner"; type AvatarSize = "sm" | "md"; @@ -14,9 +14,14 @@ const avatarImageVariants = cva("shrink-0 rounded-full object-cover", { sm: "size-6", md: "size-8", }, + grayscale: { + true: "opacity-70 grayscale", + false: "", + }, }, defaultVariants: { size: "md", + grayscale: false, }, }); @@ -33,6 +38,8 @@ interface UserAvatarProps { /** Badge shown when the email has no Gravatar or the lookup hasn't resolved. */ fallback: ReactNode; size?: AvatarSize; + /** Desaturate the image, e.g. to mark an inactive participant. */ + grayscale?: boolean; } /** @@ -49,6 +56,7 @@ export function UserAvatar({ name, fallback, size = "md", + grayscale = false, }: UserAvatarProps) { const { data: src, isLoading } = useQuery({ queryKey: UserQueryKeys.Gravatar(email, AVATAR_SIZE_PX[size]), @@ -66,7 +74,7 @@ export function UserAvatar({ src={src} title={name} alt={name} - className={avatarImageVariants({ size })} + className={avatarImageVariants({ size, grayscale })} /> ); } diff --git a/apps/web/src/features/user/model/gravatar.ts b/apps/web/src/features/user/model/gravatar.ts index 65814ec..dc18b28 100644 --- a/apps/web/src/features/user/model/gravatar.ts +++ b/apps/web/src/features/user/model/gravatar.ts @@ -4,7 +4,10 @@ * which work in the browser). `d=404` makes Gravatar 404 when no avatar exists. * Returns `null` for an empty email. */ -async function gravatarUrl(email: string, size: number): Promise { +async function gravatarUrl( + email: string, + size: number, +): Promise { const normalized = email.trim().toLowerCase(); if (!normalized) return null; diff --git a/apps/web/src/features/user/model/userQueryKeys.ts b/apps/web/src/features/user/model/userQueryKeys.ts index 01b7389..276e98e 100644 --- a/apps/web/src/features/user/model/userQueryKeys.ts +++ b/apps/web/src/features/user/model/userQueryKeys.ts @@ -3,6 +3,5 @@ */ export const UserQueryKeys = { Me: () => ["me"] as const, - Gravatar: (email: string, size: number) => - ["gravatar", email, size] as const, + Gravatar: (email: string, size: number) => ["gravatar", email, size] as const, } as const; diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 650b71d..db1dfc7 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -474,6 +474,35 @@ export interface UpdateGlobalMemoryResponse { /** Payload sent by the client when joining a session's chat room. */ export interface ChatJoinPayload { sessionId: string; + /** + * The joining human's chat identity, used to attribute live presence to the + * same author id (their email) that their messages carry. + */ + author: ChatAuthor; +} + +/** + * A human taking part in a session, surfaced as an avatar in the participant + * bar. `active` is true while the user is connected to the session over a + * socket; an inactive participant authored at least one message but is not + * currently connected. + */ +export interface SessionParticipant { + /** Human author id (their email); also resolves the Gravatar. */ + id: string; + /** Display name, e.g. "John S." */ + name: string; + /** True while connected to the session via a socket. */ + active: boolean; +} + +/** + * The session's current participant roster, emitted to the room on join and + * whenever presence changes (a user connects or disconnects). + */ +export interface ParticipantsPayload { + sessionId: string; + participants: SessionParticipant[]; } /** @@ -740,6 +769,7 @@ export const SocketEvents = { ChatJoin: "chat:join", ChatHistory: "chat:history", ChatMessage: "chat:message", + Participants: "session:participants", TerminalData: "terminal:data", AgentStart: "agent:start", AgentDelta: "agent:delta",