feat: add collapsible pod stream sidebars
This commit is contained in:
+406
-251
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
||||||
import { RoomEvent, Track } from 'livekit-client';
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
MonitorUpIcon,
|
MonitorUpIcon,
|
||||||
|
PanelLeftIcon,
|
||||||
|
PanelRightIcon,
|
||||||
RadioTowerIcon,
|
RadioTowerIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
@@ -42,6 +44,14 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@/components/ui/empty';
|
} from '@/components/ui/empty';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
} from '@/components/ui/sidebar';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -69,6 +79,15 @@ function snapshot(room: Room, fallbackName: string): PInfo[] {
|
|||||||
return [local, ...remotes];
|
return [local, ...remotes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredBool(key: string, fallback: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
return value === null ? fallback : value === 'true';
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function PodView({
|
export function PodView({
|
||||||
team,
|
team,
|
||||||
me,
|
me,
|
||||||
@@ -86,6 +105,12 @@ export function PodView({
|
|||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
const [playingBeat, setPlayingBeat] = useState(false);
|
const [playingBeat, setPlayingBeat] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
|
||||||
|
readStoredBool('podman.myStreamOpen', true),
|
||||||
|
);
|
||||||
|
const [rightStreamOpen, setRightStreamOpen] = useState(() =>
|
||||||
|
readStoredBool('podman.teamStreamOpen', true),
|
||||||
|
);
|
||||||
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
|
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
|
||||||
const activity = usePodActivity(team.id, me);
|
const activity = usePodActivity(team.id, me);
|
||||||
|
|
||||||
@@ -136,6 +161,14 @@ export function PodView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('podman.myStreamOpen', String(leftStreamOpen));
|
||||||
|
}, [leftStreamOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('podman.teamStreamOpen', String(rightStreamOpen));
|
||||||
|
}, [rightStreamOpen]);
|
||||||
|
|
||||||
async function toggleBeat() {
|
async function toggleBeat() {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
setNote(null);
|
setNote(null);
|
||||||
@@ -200,230 +233,283 @@ export function PodView({
|
|||||||
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<SidebarProvider
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
|
open={leftStreamOpen}
|
||||||
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-3 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 md:flex-row md:items-center md:justify-between lg:-mx-8 lg:px-8">
|
onOpenChange={setLeftStreamOpen}
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
style={
|
||||||
<Tooltip>
|
{
|
||||||
<TooltipTrigger asChild>
|
'--sidebar-width': '23rem',
|
||||||
<Button variant="ghost" size="icon" onClick={onLeave}>
|
'--sidebar-width-icon': '4rem',
|
||||||
<ArrowLeftIcon />
|
} as CSSProperties
|
||||||
<span className="sr-only">Leave pod</span>
|
}
|
||||||
</Button>
|
className="min-h-screen bg-background text-foreground"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>Leave pod</TooltipContent>
|
<ActivitySidebar
|
||||||
</Tooltip>
|
side="left"
|
||||||
<div className="min-w-0">
|
title="My stream"
|
||||||
<div className="flex items-center gap-2">
|
collapsedLabel="Mine"
|
||||||
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
|
description={`${me}'s live screen, git, intervention, and conflict log.`}
|
||||||
<Badge variant={room ? 'default' : 'secondary'} className="rounded-md">
|
events={activity.mine}
|
||||||
{room ? 'live' : 'local'}
|
connected={activity.connected}
|
||||||
</Badge>
|
open={leftStreamOpen}
|
||||||
</div>
|
onToggle={() => setLeftStreamOpen((open) => !open)}
|
||||||
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
|
emptyTitle="No personal signal yet"
|
||||||
</div>
|
emptyDescription="Start the local git watcher or share your IDE screen to populate this lane."
|
||||||
</div>
|
/>
|
||||||
|
<SidebarProvider
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
open={rightStreamOpen}
|
||||||
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
|
onOpenChange={setRightStreamOpen}
|
||||||
<Volume2Icon data-icon="inline-start" />
|
style={
|
||||||
{playingBeat ? 'Stop audio' : 'Test audio'}
|
{
|
||||||
</Button>
|
'--sidebar-width': '24rem',
|
||||||
<Button onClick={toggleScreen} disabled={!room}>
|
'--sidebar-width-icon': '4rem',
|
||||||
<MonitorUpIcon data-icon="inline-start" />
|
} as CSSProperties
|
||||||
{sharing ? 'Stop sharing' : 'Share screen'}
|
}
|
||||||
</Button>
|
className="min-h-screen flex-1"
|
||||||
</div>
|
>
|
||||||
</header>
|
<SidebarInset className="min-h-screen min-w-0 bg-background">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-[1240px] flex-col gap-5 px-4 py-4 sm:px-6 lg:px-8">
|
||||||
{devMode && (
|
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-3 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 md:flex-row md:items-center md:justify-between lg:-mx-8 lg:px-8">
|
||||||
<Alert>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<RadioTowerIcon />
|
<Tooltip>
|
||||||
<AlertTitle>Local mode</AlertTitle>
|
<TooltipTrigger asChild>
|
||||||
<AlertDescription>LiveKit is not configured for this session.</AlertDescription>
|
<Button
|
||||||
</Alert>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
|
onClick={() => setLeftStreamOpen((v) => !v)}
|
||||||
{note && (
|
>
|
||||||
<Alert>
|
<PanelLeftIcon />
|
||||||
<CircleDotIcon />
|
<span className="sr-only">
|
||||||
<AlertTitle>Room notice</AlertTitle>
|
{leftStreamOpen ? 'Collapse my stream' : 'Expand my stream'}
|
||||||
<AlertDescription>{note}</AlertDescription>
|
</span>
|
||||||
</Alert>
|
</Button>
|
||||||
)}
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
<main className="grid flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
{leftStreamOpen ? 'Collapse my stream' : 'Expand my stream'}
|
||||||
<section className="flex min-w-0 flex-col gap-5">
|
</TooltipContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
</Tooltip>
|
||||||
<Metric label="Participants" value={participants.length || 1} />
|
<Tooltip>
|
||||||
<Metric label="Screen" value={sharing ? 'sharing' : 'idle'} />
|
<TooltipTrigger asChild>
|
||||||
<Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
|
<Button variant="ghost" size="icon" onClick={onLeave}>
|
||||||
</div>
|
<ArrowLeftIcon />
|
||||||
|
<span className="sr-only">Leave pod</span>
|
||||||
<Card className="flex-1">
|
</Button>
|
||||||
<CardHeader>
|
</TooltipTrigger>
|
||||||
<CardTitle>Room state</CardTitle>
|
<TooltipContent>Leave pod</TooltipContent>
|
||||||
<CardDescription>People and media currently visible to PodMan.</CardDescription>
|
</Tooltip>
|
||||||
</CardHeader>
|
<div className="min-w-0">
|
||||||
<CardContent>
|
<div className="flex items-center gap-2">
|
||||||
{participants.length === 0 ? (
|
<h1 className="truncate text-xl font-semibold tracking-tight">{team.name}</h1>
|
||||||
<Empty className="min-h-80">
|
<Badge variant={room ? 'default' : 'secondary'} className="rounded-md">
|
||||||
<EmptyHeader>
|
{room ? 'live' : 'local'}
|
||||||
<EmptyMedia variant="icon">
|
</Badge>
|
||||||
<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>
|
</div>
|
||||||
)}
|
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
|
||||||
Roster: {team.members.join(', ') || 'No saved members'}
|
|
||||||
</p>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<section className="grid min-h-[420px] gap-5 xl:grid-cols-2">
|
|
||||||
<ActivityStream
|
|
||||||
title="My stream"
|
|
||||||
description={`${me}'s live screen, git, intervention, and conflict log.`}
|
|
||||||
events={activity.mine}
|
|
||||||
connected={activity.connected}
|
|
||||||
emptyTitle="No personal signal yet"
|
|
||||||
emptyDescription="Start the local git watcher or share your IDE screen to populate this lane."
|
|
||||||
/>
|
|
||||||
<ActivityStream
|
|
||||||
title="Team stream"
|
|
||||||
description="Everyone else in this pod, merged into one realtime feed."
|
|
||||||
events={activity.team}
|
|
||||||
connected={activity.connected}
|
|
||||||
emptyTitle="No teammate signal yet"
|
|
||||||
emptyDescription="Waiting for other members' screen, git, or collision events."
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
{activity.error && <p className="text-xs text-muted-foreground">{activity.error}</p>}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className="flex flex-col gap-5">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Intervention</CardTitle>
|
|
||||||
<CardDescription>Card first, voice only for urgent escalation.</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
|
|
||||||
{active ? 'active' : 'clear'}
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{active ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="rounded-lg border bg-muted/35 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>
|
|
||||||
{hermes?.interventionId === active.id && (
|
|
||||||
<div className="rounded-lg border border-dashed p-3">
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
||||||
<MessageSquareIcon className="size-3.5" />
|
|
||||||
Hermes message
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-6">{hermes.text}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{voiceCue && (
|
|
||||||
<div className="rounded-lg border border-dashed p-3">
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
||||||
<Volume2Icon className="size-3.5" />
|
|
||||||
Voice cue
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-6">{voiceCue}</p>
|
|
||||||
</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. PodMan will stay quiet until there is a useful
|
|
||||||
signal.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)}
|
|
||||||
{actionUrl && (
|
|
||||||
<a
|
|
||||||
href={actionUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm font-medium hover:bg-muted"
|
|
||||||
>
|
|
||||||
Sync PR artifact opened
|
|
||||||
<ExternalLinkIcon className="size-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
{active && (
|
|
||||||
<CardFooter className="justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => void answerIntervention('dismissed', false)}
|
|
||||||
>
|
|
||||||
<XIcon data-icon="inline-start" />
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void answerIntervention('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>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</aside>
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
||||||
</main>
|
<Button variant="outline" onClick={() => setRightStreamOpen((v) => !v)}>
|
||||||
<div ref={audioRef} className="hidden" />
|
<PanelRightIcon data-icon="inline-start" />
|
||||||
</div>
|
{rightStreamOpen ? 'Hide team' : 'Show team'}
|
||||||
</div>
|
</Button>
|
||||||
|
<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 state</CardTitle>
|
||||||
|
<CardDescription>People and media currently visible to PodMan.</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>
|
||||||
|
|
||||||
|
{activity.error && (
|
||||||
|
<p className="text-xs text-muted-foreground">{activity.error}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Intervention</CardTitle>
|
||||||
|
<CardDescription>Card first, voice only for urgent escalation.</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant={active ? 'default' : 'secondary'} className="rounded-md">
|
||||||
|
{active ? 'active' : 'clear'}
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{active ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border bg-muted/35 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>
|
||||||
|
{hermes?.interventionId === active.id && (
|
||||||
|
<div className="rounded-lg border border-dashed p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<MessageSquareIcon className="size-3.5" />
|
||||||
|
Hermes message
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6">{hermes.text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{voiceCue && (
|
||||||
|
<div className="rounded-lg border border-dashed p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<Volume2Icon className="size-3.5" />
|
||||||
|
Voice cue
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6">{voiceCue}</p>
|
||||||
|
</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. PodMan will stay quiet until there is a
|
||||||
|
useful signal.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
{actionUrl && (
|
||||||
|
<a
|
||||||
|
href={actionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Sync PR artifact opened
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{active && (
|
||||||
|
<CardFooter className="justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void answerIntervention('dismissed', false)}
|
||||||
|
>
|
||||||
|
<XIcon data-icon="inline-start" />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void answerIntervention('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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<div ref={audioRef} className="hidden" />
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
<ActivitySidebar
|
||||||
|
side="right"
|
||||||
|
title="Team stream"
|
||||||
|
collapsedLabel="Team"
|
||||||
|
description="Everyone else in this pod, merged into one realtime feed."
|
||||||
|
events={activity.team}
|
||||||
|
connected={activity.connected}
|
||||||
|
open={rightStreamOpen}
|
||||||
|
onToggle={() => setRightStreamOpen((open) => !open)}
|
||||||
|
emptyTitle="No teammate signal yet"
|
||||||
|
emptyDescription="Waiting for other members' screen, git, or collision events."
|
||||||
|
/>
|
||||||
|
</SidebarProvider>
|
||||||
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,54 +561,123 @@ function StatusLine({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityStream({
|
function ActivitySidebar({
|
||||||
|
side,
|
||||||
title,
|
title,
|
||||||
|
collapsedLabel,
|
||||||
description,
|
description,
|
||||||
events,
|
events,
|
||||||
connected,
|
connected,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
emptyTitle,
|
emptyTitle,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
}: {
|
}: {
|
||||||
|
side: 'left' | 'right';
|
||||||
title: string;
|
title: string;
|
||||||
|
collapsedLabel: string;
|
||||||
description: string;
|
description: string;
|
||||||
events: PodActivityEvent[];
|
events: PodActivityEvent[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
}) {
|
}) {
|
||||||
|
const ToggleIcon = side === 'left' ? PanelLeftIcon : PanelRightIcon;
|
||||||
|
const critical = events.filter((event) => event.severity === 'critical').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="min-h-0">
|
<Sidebar side={side} collapsible="icon" className="border-border/80 bg-sidebar">
|
||||||
<CardHeader>
|
<SidebarHeader className="border-b px-3 py-3">
|
||||||
<CardTitle>{title}</CardTitle>
|
<div className="flex min-w-0 items-start justify-between gap-2 group-data-[collapsible=icon]:hidden">
|
||||||
<CardDescription>{description}</CardDescription>
|
<div className="min-w-0">
|
||||||
<CardAction>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={connected ? 'default' : 'secondary'} className="rounded-md">
|
<h2 className="truncate text-sm font-semibold">{title}</h2>
|
||||||
{connected ? 'streaming' : 'syncing'}
|
<Badge variant={connected ? 'default' : 'secondary'} className="rounded-md">
|
||||||
|
{connected ? 'streaming' : 'syncing'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={onToggle}>
|
||||||
|
<ToggleIcon />
|
||||||
|
<span className="sr-only">{open ? `Collapse ${title}` : `Expand ${title}`}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{open ? `Collapse ${title}` : `Expand ${title}`}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="hidden min-h-24 w-full flex-col items-center justify-center gap-2 rounded-md border bg-background text-muted-foreground transition hover:bg-muted group-data-[collapsible=icon]:flex"
|
||||||
|
aria-label={`Expand ${title}`}
|
||||||
|
>
|
||||||
|
<ToggleIcon className="size-4" />
|
||||||
|
<span className="text-[0.68rem] font-medium [writing-mode:vertical-rl]">
|
||||||
|
{collapsedLabel}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={critical ? 'destructive' : 'secondary'}
|
||||||
|
className="rounded-md px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{events.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardAction>
|
</button>
|
||||||
</CardHeader>
|
</SidebarHeader>
|
||||||
<CardContent>
|
<SidebarContent className="px-3 py-3">
|
||||||
{events.length ? (
|
<div className="group-data-[collapsible=icon]:hidden">
|
||||||
<div className="max-h-[460px] overflow-y-auto pr-1">
|
{events.length ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-2 overflow-y-auto pr-1">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<ActivityItem key={event.id} event={event} />
|
<ActivityItem key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<Empty className="min-h-72 border-0 p-0">
|
||||||
<Empty className="min-h-72 border-0 p-0">
|
<EmptyHeader>
|
||||||
<EmptyHeader>
|
<EmptyMedia variant="icon">
|
||||||
<EmptyMedia variant="icon">
|
<RadioTowerIcon />
|
||||||
<RadioTowerIcon />
|
</EmptyMedia>
|
||||||
</EmptyMedia>
|
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
||||||
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
||||||
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
</EmptyHeader>
|
||||||
</EmptyHeader>
|
</Empty>
|
||||||
</Empty>
|
)}
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<div className="hidden flex-col items-center gap-2 group-data-[collapsible=icon]:flex">
|
||||||
|
{events.slice(0, 8).map((event) => {
|
||||||
|
const Icon = activityIcon(event.kind);
|
||||||
|
return (
|
||||||
|
<Tooltip key={event.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex size-9 items-center justify-center rounded-md border bg-background text-muted-foreground transition hover:bg-muted',
|
||||||
|
event.severity === 'critical' && 'border-destructive/35 text-destructive',
|
||||||
|
event.severity === 'success' && 'border-chart-2/35 text-chart-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={side === 'left' ? 'right' : 'left'}>
|
||||||
|
{event.title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,8 +262,8 @@ try {
|
|||||||
await podCard.getByPlaceholder('Your name').fill(verifyMember);
|
await podCard.getByPlaceholder('Your name').fill(verifyMember);
|
||||||
await podCard.getByRole('button', { name: 'Add and join' }).click();
|
await podCard.getByRole('button', { name: 'Add and join' }).click();
|
||||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||||
await page.getByText('My stream').waitFor({ timeout: 15_000 });
|
await page.getByRole('heading', { name: 'My stream' }).waitFor({ timeout: 15_000 });
|
||||||
await page.getByText('Team stream').waitFor({ timeout: 15_000 });
|
await page.getByRole('heading', { name: 'Team stream' }).waitFor({ timeout: 15_000 });
|
||||||
|
|
||||||
const joinedText = await page.locator('body').innerText();
|
const joinedText = await page.locator('body').innerText();
|
||||||
const hasPodView =
|
const hasPodView =
|
||||||
|
|||||||
Reference in New Issue
Block a user