chore(box): snapshot in-progress auth + user-learning WIP before deploy
Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@clerk/react": "^6.11.1",
|
||||
"@clerk/ui": "^1.23.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@podman/shared": "workspace:*",
|
||||
"@shadcn/react": "^0.1.0",
|
||||
|
||||
+142
-21
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Show,
|
||||
SignUp,
|
||||
SignInButton,
|
||||
SignUpButton,
|
||||
UserButton,
|
||||
useAuth,
|
||||
useUser,
|
||||
} from '@clerk/react';
|
||||
import type { Room } from 'livekit-client';
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -54,7 +63,13 @@ function replacePath(path: string): void {
|
||||
window.history.replaceState({}, '', path || '/');
|
||||
}
|
||||
|
||||
function firstNameFrom(value: string | null | undefined): string {
|
||||
return value?.trim().split(/\s+/).filter(Boolean)[0] ?? '';
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { getToken, isLoaded, isSignedIn } = useAuth();
|
||||
const { user } = useUser();
|
||||
const [pods, setPods] = useState<Pod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||
@@ -68,6 +83,21 @@ export default function App() {
|
||||
const [graphPodId, setGraphPodId] = useState<string | null>(null);
|
||||
|
||||
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
||||
const userEmail = user?.primaryEmailAddress?.emailAddress;
|
||||
const defaultMemberName =
|
||||
user?.firstName?.trim() ||
|
||||
firstNameFrom(user?.fullName) ||
|
||||
firstNameFrom(userEmail?.split('@')[0]);
|
||||
const currentUserProfile = {
|
||||
displayName: defaultMemberName,
|
||||
email: userEmail,
|
||||
imageUrl: user?.imageUrl,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.setAuthTokenGetter(isSignedIn ? getToken : null);
|
||||
return () => api.setAuthTokenGetter(null);
|
||||
}, [getToken, isSignedIn]);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
@@ -98,7 +128,13 @@ export default function App() {
|
||||
const previousPath = window.location.pathname;
|
||||
setPodPath(podId, replaceRoute);
|
||||
try {
|
||||
const result = await joinPod(podId, who, who);
|
||||
const result = await joinPod(
|
||||
podId,
|
||||
who,
|
||||
who,
|
||||
isSignedIn ? getToken : undefined,
|
||||
currentUserProfile,
|
||||
);
|
||||
setRoom(result.room);
|
||||
setDevMode(result.mode === 'dev');
|
||||
setMember(who);
|
||||
@@ -111,11 +147,15 @@ export default function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, []);
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (joinedPodId) return;
|
||||
if (!isSignedIn || joinedPodId) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
@@ -133,15 +173,13 @@ export default function App() {
|
||||
alive = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [joinedPodId]);
|
||||
}, [isSignedIn, joinedPodId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) return;
|
||||
const routedPodId = pathPodId();
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) {
|
||||
if (routedPodId) {
|
||||
setError(`Enter your name to join ${routedPodId}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let saved: { podId: string; member: string };
|
||||
@@ -162,10 +200,11 @@ export default function App() {
|
||||
setRestoring(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
if (!isSignedIn) return;
|
||||
const routedPodId = pathPodId();
|
||||
if (!routedPodId) {
|
||||
room?.disconnect();
|
||||
@@ -186,7 +225,7 @@ export default function App() {
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, [joinedPodId, room]);
|
||||
}, [isSignedIn, joinedPodId, room]);
|
||||
|
||||
async function run(key: string, fn: () => Promise<void>) {
|
||||
startPending(key);
|
||||
@@ -206,7 +245,7 @@ export default function App() {
|
||||
startPending('new');
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.createPod(input);
|
||||
const created = await api.createPod(input, currentUserProfile);
|
||||
setPods((cur) => [...cur, created]);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -225,7 +264,7 @@ export default function App() {
|
||||
if (pathPodId() === id) setHomePath();
|
||||
});
|
||||
const handleAddMember = (id: string, name: string) =>
|
||||
run(id, async () => upsert(await api.addMember(id, name)));
|
||||
run(id, async () => upsert(await api.addMember(id, name, currentUserProfile)));
|
||||
const handleRemoveMember = (id: string, name: string) =>
|
||||
run(id, async () => upsert(await api.removeMember(id, name)));
|
||||
|
||||
@@ -241,12 +280,15 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAndJoin(pod: Pod, name: string) {
|
||||
async function handleAddAndJoin(pod: Pod) {
|
||||
startPending(pod.id);
|
||||
setError(null);
|
||||
try {
|
||||
upsert(await api.addMember(pod.id, name));
|
||||
await connectToPod(pod.id, name);
|
||||
if (!defaultMemberName) {
|
||||
throw new Error('Sign in with Clerk before joining a pod.');
|
||||
}
|
||||
upsert(await api.addMember(pod.id, defaultMemberName, currentUserProfile));
|
||||
await connectToPod(pod.id, defaultMemberName);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -265,9 +307,28 @@ export default function App() {
|
||||
|
||||
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background text-foreground">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSignedIn) {
|
||||
return <AuthGate />;
|
||||
}
|
||||
|
||||
if (joinedPod) {
|
||||
return (
|
||||
<PodView team={joinedPod} me={member} room={room} devMode={devMode} onLeave={handleLeave} />
|
||||
<PodView
|
||||
team={joinedPod}
|
||||
me={member}
|
||||
room={room}
|
||||
devMode={devMode}
|
||||
currentUserProfile={currentUserProfile}
|
||||
onLeave={handleLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,10 +350,23 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<Button variant="outline">Sign in</Button>
|
||||
</SignInButton>
|
||||
<SignUpButton mode="modal">
|
||||
<Button>Sign up</Button>
|
||||
</SignUpButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -342,6 +416,7 @@ export default function App() {
|
||||
pod={pod}
|
||||
busy={pending.has(pod.id)}
|
||||
presence={presence[pod.id] ?? []}
|
||||
currentUserProfile={currentUserProfile}
|
||||
onJoin={handleJoin}
|
||||
onAddAndJoin={handleAddAndJoin}
|
||||
onAddMember={handleAddMember}
|
||||
@@ -362,14 +437,23 @@ export default function App() {
|
||||
<EmptyDescription>Create the first room for this team.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact />
|
||||
<CreatePodForm
|
||||
busy={pending.has('new')}
|
||||
defaultMemberName={defaultMemberName}
|
||||
onCreate={handleCreate}
|
||||
compact
|
||||
/>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-4">
|
||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
|
||||
<CreatePodForm
|
||||
busy={pending.has('new')}
|
||||
defaultMemberName={defaultMemberName}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</aside>
|
||||
</main>
|
||||
)}
|
||||
@@ -378,6 +462,43 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 py-4 sm:px-6 lg:px-8">
|
||||
<header className="flex items-center justify-between border-b pb-4 pt-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm">
|
||||
PM
|
||||
</div>
|
||||
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
|
||||
</div>
|
||||
<SignInButton mode="modal">
|
||||
<Button variant="outline">Sign in</Button>
|
||||
</SignInButton>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 items-center gap-8 py-8 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="max-w-xl">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">Team memory</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||
Create your account to enter PodMan
|
||||
</h2>
|
||||
<p className="mt-3 text-base text-muted-foreground">
|
||||
PodMan saves your context across pods so agents can learn from your work in every
|
||||
room you join.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<SignUp routing="hash" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PodSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -17,17 +17,19 @@ import { cn } from '@/lib/utils';
|
||||
export function CreatePodForm({
|
||||
busy,
|
||||
onCreate,
|
||||
defaultMemberName = '',
|
||||
compact = false,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (input: PodInput) => Promise<void>;
|
||||
defaultMemberName?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(compact);
|
||||
const [name, setName] = useState('');
|
||||
const [repo, setRepo] = useState('karti-ai/podman');
|
||||
const [description, setDescription] = useState('');
|
||||
const [firstMember, setFirstMember] = useState('');
|
||||
const [firstMember, setFirstMember] = useState(defaultMemberName);
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return;
|
||||
@@ -40,7 +42,7 @@ export function CreatePodForm({
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFirstMember('');
|
||||
setFirstMember(defaultMemberName);
|
||||
if (!compact) setOpen(false);
|
||||
} catch {
|
||||
/* parent owns the visible error */
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
VideoIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Pod, PodInput } from '@podman/shared';
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -41,6 +47,7 @@ export function PodCard({
|
||||
pod,
|
||||
busy,
|
||||
presence,
|
||||
currentUserProfile,
|
||||
onJoin: _onJoin,
|
||||
onAddAndJoin,
|
||||
onAddMember: _onAddMember,
|
||||
@@ -52,15 +59,19 @@ export function PodCard({
|
||||
pod: Pod;
|
||||
busy: boolean;
|
||||
presence: string[];
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
onJoin: (pod: Pod, member: string) => void;
|
||||
onAddAndJoin: (pod: Pod, name: string) => void;
|
||||
onAddAndJoin: (pod: Pod) => void;
|
||||
onAddMember: (id: string, name: string) => void;
|
||||
onRemoveMember: (id: string, name: string) => void;
|
||||
onUpdate: (id: string, patch: PodInput) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onOpenGraph: (id: string) => void;
|
||||
}) {
|
||||
const [newMember, setNewMember] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<PodInput>({
|
||||
name: pod.name,
|
||||
@@ -69,6 +80,13 @@ export function PodCard({
|
||||
});
|
||||
|
||||
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
||||
const profileForMember = (name: string) =>
|
||||
pod.memberProfiles?.[name] ??
|
||||
([currentUserProfile?.displayName, currentUserProfile?.email]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase() === name.toLowerCase())
|
||||
? currentUserProfile
|
||||
: undefined);
|
||||
const active = presence.length > 0;
|
||||
|
||||
function saveEdit() {
|
||||
@@ -80,13 +98,8 @@ export function PodCard({
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
// One action: enter your name, hit Join → added to the roster (deduped
|
||||
// server-side) and connected to the room in a single step.
|
||||
function join() {
|
||||
const name = newMember.trim();
|
||||
if (!name) return;
|
||||
onAddAndJoin(pod, name);
|
||||
setNewMember('');
|
||||
onAddAndJoin(pod);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -143,12 +156,16 @@ export function PodCard({
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<AvatarGroup>
|
||||
{pod.members.slice(0, 4).map((member) => (
|
||||
<Avatar key={member} title={member}>
|
||||
<AvatarFallback>{initials(member)}</AvatarFallback>
|
||||
{inRoom(member) && <AvatarBadge />}
|
||||
</Avatar>
|
||||
))}
|
||||
{pod.members.slice(0, 4).map((member) => {
|
||||
const profile = profileForMember(member);
|
||||
return (
|
||||
<Avatar key={member} title={profile?.email ?? member}>
|
||||
{profile?.imageUrl && <AvatarImage src={profile.imageUrl} alt={member} />}
|
||||
<AvatarFallback>{initials(member)}</AvatarFallback>
|
||||
{inRoom(member) && <AvatarBadge />}
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
{pod.members.length > 4 && <span className="text-sm text-muted-foreground">+</span>}
|
||||
</AvatarGroup>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -157,16 +174,8 @@ export function PodCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={newMember}
|
||||
onChange={(e) => setNewMember(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') join();
|
||||
}}
|
||||
/>
|
||||
<Button className="min-w-24" onClick={join} disabled={busy || !newMember.trim()}>
|
||||
<div className="flex justify-end">
|
||||
<Button className="min-w-24" onClick={join} disabled={busy}>
|
||||
<VideoIcon data-icon="inline-start" />
|
||||
Join
|
||||
</Button>
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -100,24 +100,46 @@ const STREAM_RAIL_WIDTH = '4rem';
|
||||
interface PInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
isLocal: boolean;
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
function profileFromMetadata(metadata?: string): { email?: string; imageUrl?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata || '{}') as { email?: unknown; imageUrl?: unknown };
|
||||
return {
|
||||
email: typeof parsed.email === 'string' ? parsed.email : undefined,
|
||||
imageUrl: typeof parsed.imageUrl === 'string' ? parsed.imageUrl : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(room: Room, fallbackName: string): PInfo[] {
|
||||
const lp = room.localParticipant;
|
||||
const localProfile = profileFromMetadata(lp.metadata);
|
||||
const local: PInfo = {
|
||||
id: lp.identity,
|
||||
name: lp.name || fallbackName,
|
||||
email: localProfile.email,
|
||||
imageUrl: localProfile.imageUrl,
|
||||
isLocal: true,
|
||||
speaking: lp.isSpeaking,
|
||||
};
|
||||
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({
|
||||
id: p.identity,
|
||||
name: p.name || p.identity,
|
||||
isLocal: false,
|
||||
speaking: p.isSpeaking,
|
||||
}));
|
||||
const remotes = Array.from(room.remoteParticipants.values()).map((p) => {
|
||||
const profile = profileFromMetadata(p.metadata);
|
||||
return {
|
||||
id: p.identity,
|
||||
name: p.name || p.identity,
|
||||
email: profile.email,
|
||||
imageUrl: profile.imageUrl,
|
||||
isLocal: false,
|
||||
speaking: p.isSpeaking,
|
||||
};
|
||||
});
|
||||
return [local, ...remotes];
|
||||
}
|
||||
|
||||
@@ -136,11 +158,17 @@ export function PodView({
|
||||
room,
|
||||
devMode,
|
||||
onLeave,
|
||||
currentUserProfile,
|
||||
}: {
|
||||
team: Pod;
|
||||
me: string;
|
||||
room: Room | null;
|
||||
devMode: boolean;
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const [participants, setParticipants] = useState<PInfo[]>([]);
|
||||
@@ -241,6 +269,7 @@ export function PodView({
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, refresh)
|
||||
.on(RoomEvent.ParticipantDisconnected, refresh)
|
||||
.on(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||
.on(RoomEvent.TrackSubscribed, onAudio)
|
||||
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||
@@ -252,6 +281,7 @@ export function PodView({
|
||||
room
|
||||
.off(RoomEvent.ParticipantConnected, refresh)
|
||||
.off(RoomEvent.ParticipantDisconnected, refresh)
|
||||
.off(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||
.off(RoomEvent.TrackSubscribed, onAudio)
|
||||
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||
@@ -738,6 +768,8 @@ export function PodView({
|
||||
<Participant
|
||||
key={p.id}
|
||||
participant={p}
|
||||
rosterProfile={team.memberProfiles?.[p.name]}
|
||||
currentUserProfile={currentUserProfile}
|
||||
onOpenHistory={setHistoryMember}
|
||||
/>
|
||||
))}
|
||||
@@ -969,11 +1001,28 @@ export function PodView({
|
||||
|
||||
function Participant({
|
||||
participant,
|
||||
rosterProfile,
|
||||
currentUserProfile,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
participant: PInfo;
|
||||
rosterProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
onOpenHistory: (member: string) => void;
|
||||
}) {
|
||||
const localProfile = participant.isLocal ? currentUserProfile : undefined;
|
||||
const profile = {
|
||||
email: participant.email ?? localProfile?.email ?? rosterProfile?.email,
|
||||
imageUrl: participant.imageUrl ?? localProfile?.imageUrl ?? rosterProfile?.imageUrl,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -983,12 +1032,15 @@ function Participant({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar>
|
||||
{profile.imageUrl && <AvatarImage src={profile.imageUrl} alt={participant.name} />}
|
||||
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
||||
{participant.speaking && <AvatarBadge />}
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{participant.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profile.email ?? (participant.isLocal ? 'you' : 'remote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
||||
+66
-30
@@ -14,11 +14,34 @@ const BACKEND_URL =
|
||||
? 'http://localhost:8787'
|
||||
: '');
|
||||
|
||||
type AuthTokenGetter = () => Promise<string | null>;
|
||||
|
||||
let authTokenGetter: AuthTokenGetter | null = null;
|
||||
|
||||
export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
|
||||
authTokenGetter = getter;
|
||||
}
|
||||
|
||||
async function requestHeaders(init?: HeadersInit): Promise<Headers> {
|
||||
const next = new Headers(init);
|
||||
const token = await authTokenGetter?.();
|
||||
if (token) next.set('authorization', `Bearer ${token}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: await requestHeaders(init.headers),
|
||||
});
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
observations: number;
|
||||
collisions: number;
|
||||
interventions: number;
|
||||
outcomes: number;
|
||||
userPodContext?: number;
|
||||
}
|
||||
|
||||
export interface LiveConversationSession {
|
||||
@@ -34,6 +57,12 @@ export interface LiveConversationSession {
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export interface UserProfilePayload {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
@@ -42,16 +71,19 @@ async function json<T>(res: Response): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
||||
|
||||
/** Mint a LiveKit token from the backend. */
|
||||
export async function fetchToken(params: {
|
||||
room: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
githubLogin?: string;
|
||||
profile?: UserProfilePayload;
|
||||
}): Promise<{ token: string; url: string }> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
||||
const res = await apiFetch(`${BACKEND_URL}/api/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return json(res);
|
||||
@@ -59,9 +91,9 @@ export async function fetchToken(params: {
|
||||
|
||||
/** Record an intervention outcome for the policy learning loop. */
|
||||
export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/outcome`, {
|
||||
const res = await apiFetch(`${BACKEND_URL}/api/outcome`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(outcome),
|
||||
});
|
||||
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
||||
@@ -73,9 +105,9 @@ export async function createSyncPr(input: {
|
||||
summary?: string;
|
||||
}): Promise<{ url: string; number: number }> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
@@ -84,21 +116,21 @@ export async function createSyncPr(input: {
|
||||
// --- Pods CRUD ---
|
||||
|
||||
export async function listPods(): Promise<Pod[]> {
|
||||
return json(await fetch(`${BACKEND_URL}/api/pods`));
|
||||
return json(await apiFetch(`${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`));
|
||||
return json(await apiFetch(`${BACKEND_URL}/api/presence`));
|
||||
}
|
||||
|
||||
export async function getMemoryStats(): Promise<MemoryStats> {
|
||||
return json(await fetch(`${BACKEND_URL}/api/memory/stats`));
|
||||
return json(await apiFetch(`${BACKEND_URL}/api/memory/stats`));
|
||||
}
|
||||
|
||||
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +148,7 @@ export async function getMemberWorkHistory(
|
||||
member: string,
|
||||
): Promise<MemberWorkHistory> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
|
||||
member,
|
||||
)}/history?hours=24&limit=80`,
|
||||
@@ -124,47 +156,51 @@ export async function getMemberWorkHistory(
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPod(input: PodInput): Promise<Pod> {
|
||||
export async function createPod(input: PodInput, profile?: UserProfilePayload): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ ...input, profile }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePod(id: string, patch: PodInput): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePod(id: string): Promise<void> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`delete pod failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function addMember(id: string, name: string): Promise<Pod> {
|
||||
export async function addMember(
|
||||
id: string,
|
||||
name: string,
|
||||
profile?: UserProfilePayload,
|
||||
): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ name, profile }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function testPodVoice(id: string): Promise<void> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
||||
const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({
|
||||
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
||||
}),
|
||||
@@ -177,16 +213,16 @@ export async function startLiveConversation(
|
||||
input: { identity: string; displayName?: string },
|
||||
): Promise<LiveConversationSession> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
|
||||
@@ -200,7 +236,7 @@ export async function getLiveConversationHermesJob(
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
|
||||
@@ -213,7 +249,7 @@ export async function abortLiveConversationHermesJob(
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null }> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`,
|
||||
@@ -224,7 +260,7 @@ export async function abortLiveConversationHermesJob(
|
||||
|
||||
export async function removeMember(id: string, name: string): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
+16
-4
@@ -11,11 +11,17 @@ export async function fetchPodToken(
|
||||
podId: string,
|
||||
identity: string,
|
||||
name: string,
|
||||
getToken?: () => Promise<string | null>,
|
||||
profile?: { displayName?: string; email?: string; imageUrl?: string },
|
||||
): Promise<{ token: string; url: string }> {
|
||||
const clerkToken = await getToken?.();
|
||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ room: podId, identity, name }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(clerkToken ? { authorization: `Bearer ${clerkToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ room: podId, identity, name, profile }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
|
||||
return res.json();
|
||||
@@ -33,8 +39,14 @@ export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: nul
|
||||
* connected — screen sharing is a separate, deliberate action (see PodView) so
|
||||
* a denied/slow screen prompt never blocks or fails the join.
|
||||
*/
|
||||
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> {
|
||||
const { token, url } = await fetchPodToken(podId, identity, name);
|
||||
export async function joinPod(
|
||||
podId: string,
|
||||
identity: string,
|
||||
name: string,
|
||||
getToken?: () => Promise<string | null>,
|
||||
profile?: { displayName?: string; email?: string; imageUrl?: string },
|
||||
): Promise<JoinResult> {
|
||||
const { token, url } = await fetchPodToken(podId, identity, name, getToken, profile);
|
||||
|
||||
if (!isLiveKitConfigured(url)) {
|
||||
console.warn('[podman] LiveKit not configured — dev mock join');
|
||||
|
||||
+14
-3
@@ -1,13 +1,24 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ClerkProvider } from '@clerk/react';
|
||||
import { shadcn } from '@clerk/ui/themes';
|
||||
import App from './App.js';
|
||||
import './index.css';
|
||||
import '@clerk/ui/themes/shadcn.css';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
|
||||
|
||||
if (!clerkPublishableKey) {
|
||||
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY');
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey} appearance={{ theme: shadcn }}>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</ClerkProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user