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;
|
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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user