feat: one-click join + live pod presence

Join UX: click a member's name to join directly, or type a name and "Join"
(adds to roster + joins) — removes the redundant dropdown step. "Add" still
adds to the roster without joining.

Presence: new GET /api/presence (LiveKit RoomServiceClient) reports who's
connected per pod. The pod list polls it and shows a "N in room" badge plus a
green dot on members currently connected, so people can see active pods and jump in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kartikeya
2026-06-27 16:46:47 -07:00
parent 8271188cf1
commit 0f3b55a4f9
5 changed files with 146 additions and 49 deletions
+38
View File
@@ -14,6 +14,7 @@ export default function App() {
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [presence, setPresence] = useState<Record<string, string[]>>({});
// join state — keep the id and derive the pod so it never goes stale
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
@@ -59,6 +60,27 @@ export default function App() {
void refresh();
}, []);
// Poll live presence while browsing the pod list (not while joined — PodView
// tracks the room live itself).
useEffect(() => {
if (joinedPodId) return;
let alive = true;
const tick = async () => {
try {
const p = await api.getPresence();
if (alive) setPresence(p);
} catch {
/* presence is best-effort */
}
};
void tick();
const id = window.setInterval(() => void tick(), 5000);
return () => {
alive = false;
window.clearInterval(id);
};
}, [joinedPodId]);
// Resume a joined session across a page refresh (auto-reconnect, no re-prompt).
useEffect(() => {
const raw = sessionStorage.getItem(SESSION_KEY);
@@ -134,6 +156,20 @@ export default function App() {
}
}
// Add your name to the roster (if new) and join in one step.
async function handleAddAndJoin(pod: Pod, name: string) {
startPending(pod.id);
setError(null);
try {
upsert(await api.addMember(pod.id, name));
await connectToPod(pod.id, name);
} catch (e) {
setError((e as Error).message);
} finally {
endPending(pod.id);
}
}
function handleLeave() {
room?.disconnect();
setRoom(null);
@@ -203,7 +239,9 @@ export default function App() {
key={pod.id}
pod={pod}
busy={pending.has(pod.id)}
presence={presence[pod.id] ?? []}
onJoin={handleJoin}
onAddAndJoin={handleAddAndJoin}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
onUpdate={handleUpdate}
+54 -49
View File
@@ -5,7 +5,9 @@ import { Avatar } from './Avatar.js';
export function PodCard({
pod,
busy,
presence,
onJoin,
onAddAndJoin,
onAddMember,
onRemoveMember,
onUpdate,
@@ -13,14 +15,15 @@ export function PodCard({
}: {
pod: Pod;
busy: boolean;
presence: string[];
onJoin: (pod: Pod, member: string) => void;
onAddAndJoin: (pod: Pod, name: string) => void;
onAddMember: (id: string, name: string) => void;
onRemoveMember: (id: string, name: string) => void;
onUpdate: (id: string, patch: PodInput) => void;
onDelete: (id: string) => void;
}) {
const [newMember, setNewMember] = useState('');
const [joinAs, setJoinAs] = useState(pod.members[0] ?? '');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PodInput>({
name: pod.name,
@@ -28,15 +31,7 @@ export function PodCard({
description: pod.description ?? '',
});
// keep joinAs valid as members change
const joinMember = pod.members.includes(joinAs) ? joinAs : (pod.members[0] ?? '');
function add() {
const name = newMember.trim();
if (!name) return;
onAddMember(pod.id, name);
setNewMember('');
}
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
function saveEdit() {
onUpdate(pod.id, {
@@ -88,13 +83,20 @@ export function PodCard({
) : (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold text-slate-100">{pod.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-100">{pod.name}</h3>
{presence.length > 0 && (
<span className="flex items-center gap-1 rounded-full bg-emerald-950/60 px-2 py-0.5 text-xs text-emerald-400">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
{presence.length} in room
</span>
)}
</div>
<p className="text-xs text-slate-500">{pod.repo || 'no repo set'}</p>
{pod.description && <p className="mt-1 text-sm text-slate-300">{pod.description}</p>}
</div>
<div className="flex shrink-0 gap-1">
<button
title="Edit pod"
className="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-800"
onClick={() => {
setDraft({ name: pod.name, repo: pod.repo, description: pod.description ?? '' });
@@ -104,7 +106,6 @@ export function PodCard({
Edit
</button>
<button
title="Delete pod"
className="rounded-md border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-50"
onClick={() => {
if (confirm(`Delete pod “${pod.name}”?`)) onDelete(pod.id);
@@ -117,74 +118,78 @@ export function PodCard({
</div>
)}
{/* Members */}
{/* Members — click your name to join */}
<div className="flex flex-col gap-2">
<p className="text-xs text-slate-500">Click your name to join:</p>
<div className="flex flex-wrap gap-1.5">
{pod.members.length === 0 && (
<span className="text-xs text-slate-600">No members yet.</span>
<span className="text-xs text-slate-600">No members yet add your name below.</span>
)}
{pod.members.map((m) => (
<span
key={m}
className="flex items-center gap-1.5 rounded-full bg-slate-800 py-0.5 pl-0.5 pr-2 text-sm text-slate-200"
className="flex items-center overflow-hidden rounded-full border border-slate-700 bg-slate-800"
>
<Avatar name={m} size={22} />
{m}
<button
title={`Remove ${m}`}
className="ml-0.5 text-slate-500 hover:text-red-400"
className="flex items-center gap-1.5 py-0.5 pl-0.5 pr-2 text-sm text-slate-100 hover:bg-emerald-900/40 disabled:opacity-50"
onClick={() => onJoin(pod, m)}
disabled={busy}
title={`Join as ${m}`}
>
<Avatar name={m} size={22} />
{m}
{inRoom(m) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />}
</button>
<button
className="px-1.5 text-slate-500 hover:bg-red-950/40 hover:text-red-400 disabled:opacity-50"
onClick={() => onRemoveMember(pod.id, m)}
disabled={busy}
title={`Remove ${m}`}
>
×
</button>
</span>
))}
</div>
{/* Add your name (and join) */}
<div className="flex gap-2">
<input
className="flex-1 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
placeholder="Add your name…"
value={newMember}
onChange={(e) => setNewMember(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && add()}
onKeyDown={(e) => {
if (e.key === 'Enter' && newMember.trim()) {
onAddAndJoin(pod, newMember.trim());
setNewMember('');
}
}}
/>
<button
className="rounded-md border border-slate-600 px-3 py-1.5 text-sm hover:bg-slate-800 disabled:opacity-50"
onClick={add}
onClick={() => {
onAddMember(pod.id, newMember.trim());
setNewMember('');
}}
disabled={busy || !newMember.trim()}
title="Add to roster without joining"
>
Add
</button>
<button
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
onClick={() => {
onAddAndJoin(pod, newMember.trim());
setNewMember('');
}}
disabled={busy || !newMember.trim()}
title="Add your name and join the room"
>
Join
</button>
</div>
</div>
{/* Join */}
<div className="flex gap-2 border-t border-slate-800 pt-3">
<select
className="flex-1 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm text-slate-100 disabled:opacity-50"
value={joinMember}
onChange={(e) => setJoinAs(e.target.value)}
disabled={pod.members.length === 0}
>
{pod.members.length === 0 ? (
<option>add a member first</option>
) : (
pod.members.map((m) => (
<option key={m} value={m}>
{m}
</option>
))
)}
</select>
<button
className="rounded-md bg-emerald-600 px-4 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
onClick={() => onJoin(pod, joinMember)}
disabled={busy || pod.members.length === 0}
>
Join
</button>
</div>
</div>
);
}
+5
View File
@@ -41,6 +41,11 @@ export async function listPods(): Promise<Pod[]> {
return json(await fetch(`${BACKEND_URL}/api/pods`));
}
/** Display names currently connected per pod id (= LiveKit room name). */
export async function getPresence(): Promise<Record<string, string[]>> {
return json(await fetch(`${BACKEND_URL}/api/presence`));
}
export async function createPod(input: PodInput): Promise<Pod> {
return json(
await fetch(`${BACKEND_URL}/api/pods`, {