Overhaul PodMan UI with shadcn

This commit is contained in:
Yahya Alhinai
2026-06-28 01:04:25 +00:00
parent 80cc89072f
commit 5185845090
89 changed files with 13537 additions and 628 deletions
+249 -183
View File
@@ -1,9 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { RoomEvent, Track } from 'livekit-client';
import {
ArrowLeftIcon,
CheckIcon,
CircleDotIcon,
MonitorUpIcon,
RadioTowerIcon,
SparklesIcon,
Volume2Icon,
XIcon,
} from 'lucide-react';
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
import type { Pod } from '@podman/shared';
import { Avatar } from './Avatar.js';
import { startBeat, type BeatHandle } from '../lib/beat.js';
import { useInterventions } from '../livekit/useInterventions.js';
import LiveWaveform from '@/components/ruixen/live-waveform';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface PInfo {
id: string;
@@ -46,6 +80,7 @@ export function PodView({
const [sharing, setSharing] = useState(false);
const [playingBeat, setPlayingBeat] = useState(false);
const [note, setNote] = useState<string | null>(null);
const { active, respond } = useInterventions(room);
const audioRef = useRef<HTMLDivElement>(null);
const beatRef = useRef<BeatHandle | null>(null);
@@ -53,7 +88,6 @@ export function PodView({
const onLeaveRef = useRef(onLeave);
onLeaveRef.current = onLeave;
// Subscribe to live room state: participants, active speakers, remote audio.
useEffect(() => {
if (!room) return;
const refresh = () => setParticipants(snapshot(room, me));
@@ -65,7 +99,6 @@ export function PodView({
}
};
const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove());
// Room closed out from under us (e.g. the pod was deleted) → back to the list.
const onDisconnected = () => onLeaveRef.current();
room
@@ -87,7 +120,6 @@ export function PodView({
};
}, [room, me]);
// Stop the beat and any active screen capture on leave/unmount.
useEffect(() => {
return () => {
beatRef.current?.stop();
@@ -114,7 +146,7 @@ export function PodView({
setPlayingBeat(true);
}
} catch (e) {
setNote(`beat failed: ${(e as Error).message}`);
setNote(`Audio test failed: ${(e as Error).message}`);
}
}
@@ -131,7 +163,7 @@ export function PodView({
return;
}
if (!window.isSecureContext || !navigator.mediaDevices?.getDisplayMedia) {
setNote('screen capture needs HTTPS (secure context)');
setNote('Screen capture needs HTTPS.');
return;
}
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
@@ -145,205 +177,239 @@ export function PodView({
screenTrackRef.current = track;
setSharing(true);
} catch (e) {
setNote(`screen share cancelled: ${(e as Error).message}`);
setNote(`Screen share stopped: ${(e as Error).message}`);
}
}
const liveCount = participants.length;
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-semibold text-slate-950">{team.name}</h2>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
room
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
}`}
>
{room ? 'live room' : 'local'}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">{team.repo}</p>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
Presence, media controls, and intervention state for this pod.
</p>
</div>
<button
onClick={onLeave}
className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
Leave
</button>
</header>
{devMode && (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Dev mode: LiveKit is not configured, so this is a local-only mock.
</p>
)}
<section className="grid gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm md:grid-cols-[1fr_auto] md:items-center">
<div>
<p className="text-xs font-medium text-slate-500">Broadcast controls</p>
<p className="mt-1 text-sm text-slate-600">Audio, screen, and room signal.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={toggleBeat}
disabled={!room}
className={`rounded-md px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
playingBeat
? 'bg-red-600 text-white hover:bg-red-500'
: 'bg-emerald-600 text-white hover:bg-emerald-500'
}`}
>
{playingBeat ? 'Stop beat' : 'Play beat'}
</button>
<button
onClick={toggleScreen}
disabled={!room}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
{sharing ? 'Stop sharing' : 'Share screen'}
</button>
</div>
</section>
{note && (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{note}
</p>
)}
<div className="grid gap-4 md:grid-cols-4">
<RoomMetric label="Participants" value={liveCount} />
<RoomMetric label="Screen share" value={sharing ? 'on' : 'off'} />
<RoomMetric label="Audio test" value={playingBeat ? 'on' : 'idle'} />
<RoomMetric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-slate-950">Room presence</h3>
<p className="text-sm text-slate-500">Live participants and speaking state.</p>
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
<header className="flex items-center justify-between gap-4 border-b pb-4">
<div className="flex min-w-0 items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onLeave}>
<ArrowLeftIcon />
<span className="sr-only">Leave pod</span>
</Button>
</TooltipTrigger>
<TooltipContent>Leave pod</TooltipContent>
</Tooltip>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
<Badge variant={room ? 'default' : 'secondary'}>{room ? 'live' : 'local'}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{team.repo}</p>
</div>
<span className="rounded-full bg-slate-50 px-2.5 py-1 text-xs text-slate-700 ring-1 ring-slate-200">
{liveCount} connected
</span>
</div>
{liveCount === 0 ? (
<p className="text-sm text-slate-500">Connecting...</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{participants.map((p) => (
<div
key={p.id}
className={`flex min-h-20 items-center gap-3 rounded-lg border p-3 transition ${
p.speaking
? 'border-emerald-200 bg-emerald-50 ring-1 ring-emerald-100'
: 'border-slate-200 bg-slate-50'
}`}
>
<Avatar name={p.name} size={38} ring={p.isLocal} />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950">{p.name}</p>
<p className="mt-0.5 text-xs text-slate-500">
{p.isLocal ? 'you' : 'connected'}
{p.speaking ? ' - speaking' : ''}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
<Volume2Icon data-icon="inline-start" />
{playingBeat ? 'Stop audio' : 'Test audio'}
</Button>
<Button onClick={toggleScreen} disabled={!room}>
<MonitorUpIcon data-icon="inline-start" />
{sharing ? 'Stop sharing' : 'Share screen'}
</Button>
</div>
</header>
{devMode && (
<Alert>
<RadioTowerIcon />
<AlertTitle>Local mode</AlertTitle>
<AlertDescription>LiveKit is not configured for this session.</AlertDescription>
</Alert>
)}
{note && (
<Alert>
<CircleDotIcon />
<AlertTitle>Room notice</AlertTitle>
<AlertDescription>{note}</AlertDescription>
</Alert>
)}
<main className="grid flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="flex min-w-0 flex-col gap-5">
<div className="grid gap-3 sm:grid-cols-3">
<Metric label="Participants" value={participants.length || 1} />
<Metric label="Screen" value={sharing ? 'sharing' : 'idle'} />
<Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
</div>
<Card className="flex-1">
<CardHeader>
<CardTitle>Room</CardTitle>
<CardDescription>Only the signals that matter right now.</CardDescription>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<Empty className="min-h-80">
<EmptyHeader>
<EmptyMedia variant="icon">
<RadioTowerIcon />
</EmptyMedia>
<EmptyTitle>Connecting</EmptyTitle>
<EmptyDescription>Waiting for LiveKit room state.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="grid gap-2 md:grid-cols-2">
{participants.map((p) => (
<Participant key={p.id} participant={p} />
))}
</div>
)}
</CardContent>
<CardFooter>
<p className="truncate text-sm text-muted-foreground">
Roster: {team.members.join(', ') || 'No saved members'}
</p>
</CardFooter>
</Card>
</section>
<aside className="flex flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Intervention</CardTitle>
<CardDescription>PodMan stays quiet until the signal is useful.</CardDescription>
<CardAction>
<Badge variant={active ? 'default' : 'secondary'}>
{active ? 'active' : 'clear'}
</Badge>
</CardAction>
</CardHeader>
<CardContent>
{active ? (
<div className="flex flex-col gap-4">
<div className="rounded-xl border bg-muted/30 p-3">
<p className="text-sm leading-6">{active.message}</p>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Suggested action</span>
<Badge variant="outline">
{active.suggestedAction.kind.replaceAll('_', ' ')}
</Badge>
</div>
</div>
) : (
<Empty className="min-h-72 border-0 p-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No collision detected</EmptyTitle>
<EmptyDescription>
Share your screen when ready. The agent will surface only meaningful
overlap.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
{active && (
<CardFooter className="justify-end gap-2">
<Button variant="outline" onClick={() => void respond('dismissed', false)}>
<XIcon data-icon="inline-start" />
Dismiss
</Button>
<Button onClick={() => void respond('accepted', true)}>
<CheckIcon data-icon="inline-start" />
Accept
</Button>
</CardFooter>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Connection, media, and agent presence.</CardDescription>
</CardHeader>
<CardContent>
<LiveWaveform
processing={!!room}
active={false}
height={32}
barWidth={2}
barGap={3}
className="mb-4 px-3 py-2 text-muted-foreground"
/>
<div className="flex flex-col gap-3">
<StatusLine label="LiveKit" value={room ? 'connected' : 'offline'} />
<StatusLine label="Screen" value={sharing ? 'published' : 'not shared'} />
<StatusLine label="Audio" value={playingBeat ? 'publishing' : 'ready'} />
<StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} />
</div>
))}
</div>
)}
<p className="mt-4 text-xs leading-5 text-slate-600">
Pod roster: {team.members.join(', ') || '-'}
</p>
</section>
<aside className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-slate-500">Intervention rail</p>
<h3 className="mt-1 text-base font-semibold text-slate-950">PodMan</h3>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
podmanPresent
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
: 'bg-slate-100 text-slate-500 ring-1 ring-slate-200'
}`}
>
{podmanPresent ? 'watching' : 'waiting'}
</span>
</div>
<div className="mt-5 space-y-3">
<InterventionCard
state="ready"
title="Live inference"
text={
sharing ? 'Screen frames available to the agent.' : 'Waiting for screen signal.'
}
/>
<InterventionCard
state="quiet"
title="Collision detector"
text="No same-file collision has been detected in this room."
/>
<InterventionCard
state="quiet"
title="Escalation policy"
text="Cards first; voice only when urgency crosses the threshold."
/>
</div>
</aside>
</CardContent>
</Card>
</aside>
</main>
<div ref={audioRef} className="hidden" />
</div>
<div ref={audioRef} className="hidden" />
</div>
);
}
function RoomMetric({ label, value }: { label: string; value: number | string }) {
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3 shadow-sm">
<p className="text-[11px] font-medium text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold text-slate-950">{value}</p>
<Card size="sm">
<CardContent>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-lg font-medium">{value}</p>
</CardContent>
</Card>
);
}
function Participant({ participant }: { participant: PInfo }) {
return (
<div
className={cn(
'flex min-h-16 items-center justify-between gap-3 rounded-xl border bg-muted/20 px-3 transition',
participant.speaking && 'bg-muted',
)}
>
<div className="flex min-w-0 items-center gap-3">
<Avatar>
<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>
</div>
</div>
<Badge variant={participant.speaking ? 'default' : 'secondary'}>
{participant.speaking ? 'speaking' : 'connected'}
</Badge>
</div>
);
}
function InterventionCard({
state,
title,
text,
}: {
state: 'ready' | 'quiet';
title: string;
text: string;
}) {
function StatusLine({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-950">{title}</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${
state === 'ready'
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
: 'bg-white text-slate-500 ring-1 ring-slate-200'
}`}
>
{state}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">{text}</p>
</div>
<Separator />
</>
);
}
function initials(name: string): string {
return name
.split(/\s+/)
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();
}