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:
Yahya Alhinai
2026-06-28 17:49:45 +00:00
parent 63a411490c
commit 454fdab13b
24 changed files with 4684 additions and 412 deletions
+2
View File
@@ -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
View File
@@ -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>
+4 -2
View File
@@ -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 */
+34 -25
View File
@@ -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>
+60 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>,
);