feat(api+web): member sidebar in topic chat with live presence
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

GET /v1/members lists every non-revoked member of the api key's
mesh, decorated with online state from presence rows. Distinct from
/v1/peers (active sessions) — sidebars want roster + live dot, not
just whoever is currently connected.

Chat panel splits into a 2-column layout (>=lg) with a 180px
sidebar that polls the roster every 20s. Online members go up top
with status-coloured dots (idle=green, working=clay, dnd=fig);
offline members fade below at 50% opacity. Bots get a "bot" tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 19:10:26 +01:00
parent 541440c357
commit a75483b3c2
2 changed files with 192 additions and 0 deletions

View File

@@ -13,6 +13,18 @@ interface TopicMessage {
createdAt: string; 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 { interface Props {
topicName: string; topicName: string;
topicId: string; topicId: string;
@@ -122,6 +134,7 @@ export function TopicChatPanel({
apiKeyExpiresAt, apiKeyExpiresAt,
}: Props) { }: Props) {
const [messages, setMessages] = useState<TopicMessage[]>([]); const [messages, setMessages] = useState<TopicMessage[]>([]);
const [members, setMembers] = useState<MeshMember[]>([]);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -184,6 +197,31 @@ export function TopicChatPanel({
void markRead(); void markRead();
}, [loadHistory, 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 // SSE subscription with auto-reconnect. AbortController unwinds the
// stream when the component unmounts or the topic/key changes. // stream when the component unmounts or the topic/key changes.
useEffect(() => { useEffect(() => {
@@ -310,6 +348,8 @@ export function TopicChatPanel({
? "reconnecting…" ? "reconnecting…"
: "stopped"; : "stopped";
const onlineCount = members.filter((m) => m.online).length;
return ( return (
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]"> <div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header — mono strip, clay-pulse dot, metadata right */} {/* Header — mono strip, clay-pulse dot, metadata right */}
@@ -328,6 +368,8 @@ export function TopicChatPanel({
</span> </span>
</div> </div>
{/* Body — message stream + member sidebar */}
<div className="flex flex-1 overflow-hidden">
{/* Message stream */} {/* Message stream */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4"> <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.length === 0 ? ( {messages.length === 0 ? (
@@ -364,6 +406,93 @@ export function TopicChatPanel({
)} )}
</div> </div>
{/* Member sidebar — roster with online dot */}
<aside className="hidden w-[180px] shrink-0 flex-col border-l border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 lg:flex">
<div
className="border-b border-[var(--cm-border)] px-3 py-2 text-[10px] uppercase tracking-[0.14em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
{onlineCount}/{members.length} online
</div>
<ol className="flex-1 overflow-y-auto py-2">
{members.length === 0 ? (
<li
className="px-3 py-4 text-center text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
loading
</li>
) : (
<>
{members.filter((m) => m.online).map((m) => (
<li
key={m.memberId}
className="group flex items-center gap-2 px-3 py-1.5"
title={m.summary ?? `${m.role} · ${m.pubkey.slice(0, 12)}`}
>
<span
className={
"inline-block h-1.5 w-1.5 shrink-0 rounded-full " +
(m.status === "dnd"
? "bg-[#c46686]"
: m.status === "working"
? "bg-[var(--cm-clay)]"
: "bg-emerald-500")
}
/>
<span
className="truncate text-[11px] text-[var(--cm-fg)]"
style={monoStyle}
>
{m.displayName}
</span>
{!m.isHuman ? (
<span
className="text-[8px] uppercase tracking-[0.1em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
bot
</span>
) : null}
</li>
))}
{onlineCount > 0 && onlineCount < members.length ? (
<li
className="mt-3 border-t border-[var(--cm-border)] px-3 pb-1 pt-3 text-[9px] uppercase tracking-[0.14em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
offline · {members.length - onlineCount}
</li>
) : null}
{members.filter((m) => !m.online).map((m) => (
<li
key={m.memberId}
className="flex items-center gap-2 px-3 py-1.5 opacity-50"
title={`${m.role} · ${m.pubkey.slice(0, 12)}`}
>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-fg-tertiary)]" />
<span
className="truncate text-[11px] text-[var(--cm-fg-secondary)]"
style={monoStyle}
>
{m.displayName}
</span>
{!m.isHuman ? (
<span
className="text-[8px] uppercase tracking-[0.1em] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
bot
</span>
) : null}
</li>
))}
</>
)}
</ol>
</aside>
</div>
{/* Compose */} {/* Compose */}
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3"> <div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3">
{error ? ( {error ? (

View File

@@ -444,6 +444,69 @@ export const v1Router = new Hono<Env>()
}); });
}) })
// 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 // GET /v1/peers — connected peers in the key's mesh
// Dedupe by memberId — a member can have multiple active presence // Dedupe by memberId — a member can have multiple active presence
// rows (one per session). Status reflects the most recent presence; // rows (one per session). Status reflects the most recent presence;