Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 180 additions & 11 deletions apps/server/src/sockets/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
type MemoryDismissPayload,
type MemoryScope,
type MemorySuggestionPayload,
type ParticipantsPayload,
PI_AGENT,
type SessionParticipant,
type SessionStatusPayload,
type SessionStatusSnapshotPayload,
SocketEvents,
Expand Down Expand Up @@ -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<string, { name: string; count: number }>
>();
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<string, SessionParticipant>();
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<void> {
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,
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -633,6 +766,7 @@ async function handleChatJoin(
store: SessionStore,
pi: PiAgentManager,
triggerEngine: TriggerEngine,
presence: SessionPresence,
payload: ChatJoinPayload,
): Promise<void> {
const session = await store.getSession(payload?.sessionId);
Expand All @@ -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.
Expand All @@ -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<void> {
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);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/features/chat/components/PrimeChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type MemorySuggestionPayload,
type MessageDelivery,
PI_AGENT,
type SessionParticipant,
type SubagentInfo,
type Trigger,
} from "@tangent/shared/contracts";
Expand All @@ -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,
Expand All @@ -33,6 +35,7 @@ type SendFn = (
interface PrimeChatPanelProps {
sessionId: string;
messages: ChatMessage[];
participants: SessionParticipant[];
currentAuthorId: string;
bundleId?: string;
connected: boolean;
Expand Down Expand Up @@ -61,6 +64,7 @@ interface PrimeChatPanelProps {
export function PrimeChatPanel({
sessionId,
messages,
participants,
currentAuthorId,
bundleId,
connected,
Expand All @@ -87,6 +91,11 @@ export function PrimeChatPanel({
}: PrimeChatPanelProps) {
return (
<BlockStack grow>
{participants.length > 0 && (
<Box paddingInline="base" paddingBlock="sm" borderBlockEnd="sm">
<SessionParticipants participants={participants} />
</Box>
)}
<ChatMessageList
sessionId={sessionId}
messages={messages}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/chat/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function SessionChat({ sessionId }: SessionChatProps) {
const {
messages,
subagents,
participants,
triggers,
artifacts,
pinnedPaths,
Expand Down Expand Up @@ -207,6 +208,7 @@ export function SessionChat({ sessionId }: SessionChatProps) {
<PrimeChatPanel
sessionId={sessionId}
messages={primeMessages}
participants={participants}
currentAuthorId={currentAuthorId}
bundleId={bundleId}
connected={connected}
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/features/chat/components/SessionParticipants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { SessionParticipant } from "@tangent/shared/contracts";

import { UserAvatar } from "@/features/user/components/UserAvatar";
import { cn } from "@/shared/lib/utils";

interface SessionParticipantsProps {
participants: SessionParticipant[];
}

/** First-letter initials from a display name, e.g. "John S." -> "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 `<div>`/`<span>` (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
<div className="flex -space-x-2">
{participants.map((participant) => {
const title = participant.active
? `${participant.name} (active)`
: participant.name;
const fallback = (
// local primitive
<div
title={title}
aria-label={title}
className={cn(
"flex size-6 items-center justify-center rounded-full bg-secondary text-[10px] font-medium text-secondary-foreground",
!participant.active && "opacity-70 grayscale",
)}
>
{initialsFromName(participant.name)}
</div>
);

return (
// local primitive
<span
key={participant.id}
title={title}
className="rounded-full ring-2 ring-background"
>
<UserAvatar
email={participant.id}
name={title}
size="sm"
grayscale={!participant.active}
fallback={fallback}
/>
</span>
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
Loading
Loading