feat(api+web): member sidebar in topic chat with live presence
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:
@@ -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<TopicMessage[]>([]);
|
||||
const [members, setMembers] = useState<MeshMember[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<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 */}
|
||||
@@ -328,6 +368,8 @@ export function TopicChatPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body — message stream + member sidebar */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Message stream */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 ? (
|
||||
@@ -364,6 +406,93 @@ export function TopicChatPanel({
|
||||
)}
|
||||
</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 */}
|
||||
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 p-3">
|
||||
{error ? (
|
||||
|
||||
@@ -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
|
||||
// Dedupe by memberId — a member can have multiple active presence
|
||||
// rows (one per session). Status reflects the most recent presence;
|
||||
|
||||
Reference in New Issue
Block a user