Overhaul PodMan UI with shadcn
This commit is contained in:
+249
-183
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user