diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 52e66a0..c2270a1 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -13,6 +13,18 @@ interface TopicMessage { createdAt: string; } +interface MeshMember { + memberId: string; + pubkey: string; + displayName: string; + role: string; + isHuman: boolean; + joinedAt: string; + online: boolean; + status: string; + summary: string | null; +} + interface Props { topicName: string; topicId: string; @@ -122,6 +134,7 @@ export function TopicChatPanel({ apiKeyExpiresAt, }: Props) { const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [sending, setSending] = useState(false); @@ -184,6 +197,31 @@ export function TopicChatPanel({ void markRead(); }, [loadHistory, markRead]); + // Roster — refresh every 20s so online state stays roughly current. + // Tighter cadence isn't worth a dedicated SSE channel for v1.6.x. + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const res = await fetch("/api/v1/members", { + headers, + cache: "no-store", + }); + if (!res.ok) return; + const json = (await res.json()) as { members: MeshMember[] }; + if (!cancelled) setMembers(json.members); + } catch { + // Soft-fail — sidebar will just show whatever we last had. + } + }; + void load(); + const t = setInterval(load, 20_000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [headers]); + // SSE subscription with auto-reconnect. AbortController unwinds the // stream when the component unmounts or the topic/key changes. useEffect(() => { @@ -310,6 +348,8 @@ export function TopicChatPanel({ ? "reconnecting…" : "stopped"; + const onlineCount = members.filter((m) => m.online).length; + return (
{/* Header — mono strip, clay-pulse dot, metadata right */} @@ -328,6 +368,8 @@ export function TopicChatPanel({
+ {/* Body — message stream + member sidebar */} +
{/* Message stream */}
{messages.length === 0 ? ( @@ -364,6 +406,93 @@ export function TopicChatPanel({ )}
+ {/* Member sidebar — roster with online dot */} + +
+ {/* Compose */}
{error ? ( diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index e89db41..d9c2bc0 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -444,6 +444,69 @@ export const v1Router = new Hono() }); }) + // GET /v1/members — every (non-revoked) member of the key's mesh, + // decorated with online status from presence. Unlike /v1/peers this + // includes humans/agents that haven't opened a WS session yet — + // useful for Discord-style member sidebars where roster matters more + // than live activity. + .get("/members", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const rows = await db + .select({ + memberId: meshMember.id, + pubkey: meshMember.peerPubkey, + displayName: meshMember.displayName, + role: meshMember.role, + joinedAt: meshMember.joinedAt, + userId: meshMember.userId, + }) + .from(meshMember) + .where( + and(eq(meshMember.meshId, key.meshId), isNull(meshMember.revokedAt)), + ) + .orderBy(asc(meshMember.joinedAt)); + const onlineRows = await db + .select({ + memberId: presence.memberId, + status: presence.status, + summary: presence.summary, + }) + .from(presence) + .innerJoin(meshMember, eq(presence.memberId, meshMember.id)) + .where( + and(eq(meshMember.meshId, key.meshId), isNull(presence.disconnectedAt)), + ) + .orderBy(desc(presence.connectedAt)); + const onlineByMember = new Map< + string, + { status: string; summary: string | null } + >(); + for (const r of onlineRows) { + if (onlineByMember.has(r.memberId)) continue; + onlineByMember.set(r.memberId, { + status: r.status, + summary: r.summary, + }); + } + return c.json({ + members: rows.map((r) => { + const live = onlineByMember.get(r.memberId); + return { + memberId: r.memberId, + pubkey: r.pubkey, + displayName: r.displayName, + role: r.role, + isHuman: r.userId !== null, + joinedAt: r.joinedAt.toISOString(), + online: !!live, + status: live?.status ?? "offline", + summary: live?.summary ?? null, + }; + }), + }); + }) + // GET /v1/peers — connected peers in the key's mesh // Dedupe by memberId — a member can have multiple active presence // rows (one per session). Status reflects the most recent presence;