import { useEffect, useState } from 'react'; import type { Room } from 'livekit-client'; import type { Pod, PodInput } from '@podman/shared'; import { joinPod } from './lib/pod.js'; import * as api from './lib/api.js'; import { PodCard } from './components/PodCard.js'; import { CreatePodForm } from './components/CreatePodForm.js'; import { PodView } from './components/PodView.js'; const SESSION_KEY = 'podman.session'; const fmt = new Intl.NumberFormat('en', { notation: 'compact' }); export default function App() { const [pods, setPods] = useState([]); const [loading, setLoading] = useState(true); const [pending, setPending] = useState>(new Set()); const [error, setError] = useState(null); const [presence, setPresence] = useState>({}); const [memory, setMemory] = useState(null); // join state — keep the id and derive the pod so it never goes stale const [joinedPodId, setJoinedPodId] = useState(null); const [member, setMember] = useState(''); const [devMode, setDevMode] = useState(false); const [room, setRoom] = useState(null); const [restoring, setRestoring] = useState(false); const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null; async function refresh() { setLoading(true); try { const [nextPods, nextPresence, nextMemory] = await Promise.all([ api.listPods(), api.getPresence().catch(() => presence), api.getMemoryStats().catch(() => memory), ]); setPods(nextPods); setPresence(nextPresence); setMemory(nextMemory); setError(null); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } } const startPending = (key: string) => setPending((s) => new Set(s).add(key)); const endPending = (key: string) => setPending((s) => { const n = new Set(s); n.delete(key); return n; }); // Connect to a pod's LiveKit room and persist the session for refresh-resume. async function connectToPod(podId: string, who: string) { // Stable identity per member so a refresh/rejoin replaces the existing // session instead of leaving a ghost participant behind. const identity = who; const result = await joinPod(podId, identity, who); setRoom(result.room); setDevMode(result.mode === 'dev'); setMember(who); setJoinedPodId(podId); sessionStorage.setItem(SESSION_KEY, JSON.stringify({ podId, member: who })); } useEffect(() => { 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, m] = await Promise.all([ api.getPresence(), api.getMemoryStats().catch(() => memory), ]); if (alive) { setPresence(p); setMemory(m); } } 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); if (!raw) return; let saved: { podId: string; member: string }; try { saved = JSON.parse(raw); } catch { sessionStorage.removeItem(SESSION_KEY); return; } setRestoring(true); void (async () => { try { await connectToPod(saved.podId, saved.member); } catch { sessionStorage.removeItem(SESSION_KEY); } finally { setRestoring(false); } })(); }, []); async function run(key: string, fn: () => Promise) { startPending(key); setError(null); try { await fn(); } catch (e) { setError((e as Error).message); } finally { endPending(key); } } const upsert = (p: Pod) => setPods((cur) => cur.map((x) => (x.id === p.id ? p : x))); async function handleCreate(input: PodInput): Promise { startPending('new'); setError(null); try { const created = await api.createPod(input); setPods((cur) => [...cur, created]); } catch (e) { setError((e as Error).message); throw e; } finally { endPending('new'); } } const handleUpdate = (id: string, patch: PodInput) => run(id, async () => upsert(await api.updatePod(id, patch))); const handleDelete = (id: string) => run(id, async () => { await api.deletePod(id); setPods((cur) => cur.filter((x) => x.id !== id)); }); const handleAddMember = (id: string, name: string) => run(id, async () => upsert(await api.addMember(id, name))); const handleRemoveMember = (id: string, name: string) => run(id, async () => upsert(await api.removeMember(id, name))); async function handleJoin(pod: Pod, who: string) { startPending(pod.id); setError(null); try { await connectToPod(pod.id, who); } catch (e) { setError((e as Error).message); } finally { endPending(pod.id); } } // 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); setJoinedPodId(null); sessionStorage.removeItem(SESSION_KEY); void refresh(); // re-sync the list (e.g. if the pod was deleted out from under us) } const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null); const liveNames = Array.from(new Set(Object.values(presence).flat())); const liveTotal = liveNames.length; const totalMembers = pods.reduce((sum, p) => sum + p.members.length, 0); const activeRooms = Object.values(presence).filter((names) => names.length > 0).length; const podManOnline = liveNames.some((name) => name.toLowerCase() === 'podman'); const latestActivity = memory ? memory.observations + memory.collisions + memory.interventions + memory.outcomes : 0; return (
PM

Live team coordination

PodMan

Active work, team memory, and coordination signals in one quiet workspace.

{joinedPod ? ( ) : showReconnecting ? (

Reconnecting to your pod...

) : (

Team workspaces

Pods, presence, and intervention state.

{error && (

{error}

)} {loading ? (
) : (
{pods.map((pod) => ( ))}
)}
)}
); } function Metric({ label, value, tone }: { label: string; value: string; tone: string }) { const toneClass = tone === 'green' ? 'text-emerald-700 bg-emerald-50 border-emerald-200' : tone === 'blue' ? 'text-blue-700 bg-blue-50 border-blue-200' : tone === 'amber' ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-slate-700 bg-white border-slate-200'; return (

{label}

{value}

); } function SignalRow({ label, state }: { label: string; state: string }) { return (
{label} {state}
); } function TimelineItem({ title, text }: { title: string; text: string }) { return (

{title}

{text}

); } function SkeletonCard() { return (
); }