feat: add collapsible pod stream sidebars

This commit is contained in:
Yahya Alhinai
2026-06-28 04:03:25 +00:00
parent 59f92f18df
commit 563bc6f8fd
2 changed files with 408 additions and 253 deletions
+194 -39
View File
@@ -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,10 +233,61 @@ 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}
onOpenChange={setLeftStreamOpen}
style={
{
'--sidebar-width': '23rem',
'--sidebar-width-icon': '4rem',
} as CSSProperties
}
className="min-h-screen bg-background text-foreground"
>
<ActivitySidebar
side="left"
title="My stream"
collapsedLabel="Mine"
description={`${me}'s live screen, git, intervention, and conflict log.`}
events={activity.mine}
connected={activity.connected}
open={leftStreamOpen}
onToggle={() => setLeftStreamOpen((open) => !open)}
emptyTitle="No personal signal yet"
emptyDescription="Start the local git watcher or share your IDE screen to populate this lane."
/>
<SidebarProvider
open={rightStreamOpen}
onOpenChange={setRightStreamOpen}
style={
{
'--sidebar-width': '24rem',
'--sidebar-width-icon': '4rem',
} as CSSProperties
}
className="min-h-screen flex-1"
>
<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">
<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"> <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">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setLeftStreamOpen((v) => !v)}
>
<PanelLeftIcon />
<span className="sr-only">
{leftStreamOpen ? 'Collapse my stream' : 'Expand my stream'}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{leftStreamOpen ? 'Collapse my stream' : 'Expand my stream'}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onLeave}> <Button variant="ghost" size="icon" onClick={onLeave}>
@@ -225,6 +309,10 @@ export function PodView({
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center"> <div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button variant="outline" onClick={() => setRightStreamOpen((v) => !v)}>
<PanelRightIcon data-icon="inline-start" />
{rightStreamOpen ? 'Hide team' : 'Show team'}
</Button>
<Button variant="outline" onClick={toggleBeat} disabled={!room}> <Button variant="outline" onClick={toggleBeat} disabled={!room}>
<Volume2Icon data-icon="inline-start" /> <Volume2Icon data-icon="inline-start" />
{playingBeat ? 'Stop audio' : 'Test audio'} {playingBeat ? 'Stop audio' : 'Test audio'}
@@ -291,25 +379,9 @@ export function PodView({
</CardFooter> </CardFooter>
</Card> </Card>
<section className="grid min-h-[420px] gap-5 xl:grid-cols-2"> {activity.error && (
<ActivityStream <p className="text-xs text-muted-foreground">{activity.error}</p>
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> </section>
<aside className="flex flex-col gap-5"> <aside className="flex flex-col gap-5">
@@ -362,8 +434,8 @@ export function PodView({
</EmptyMedia> </EmptyMedia>
<EmptyTitle>No collision detected</EmptyTitle> <EmptyTitle>No collision detected</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Share your screen when ready. PodMan will stay quiet until there is a useful Share your screen when ready. PodMan will stay quiet until there is a
signal. useful signal.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
@@ -423,7 +495,21 @@ export function PodView({
</main> </main>
<div ref={audioRef} className="hidden" /> <div ref={audioRef} className="hidden" />
</div> </div>
</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,41 +561,82 @@ 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">
<h2 className="truncate text-sm font-semibold">{title}</h2>
<Badge variant={connected ? 'default' : 'secondary'} className="rounded-md"> <Badge variant={connected ? 'default' : 'secondary'} className="rounded-md">
{connected ? 'streaming' : 'syncing'} {connected ? 'streaming' : 'syncing'}
</Badge> </Badge>
</CardAction> </div>
</CardHeader> <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
<CardContent> </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>
</button>
</SidebarHeader>
<SidebarContent className="px-3 py-3">
<div className="group-data-[collapsible=icon]:hidden">
{events.length ? ( {events.length ? (
<div className="max-h-[460px] overflow-y-auto pr-1"> <div className="flex max-h-[calc(100svh-9rem)] flex-col gap-2 overflow-y-auto pr-1">
<div className="flex flex-col gap-2">
{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>
@@ -521,8 +648,36 @@ function ActivityStream({
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
)} )}
</CardContent> </div>
</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>
); );
} }
+2 -2
View File
@@ -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 =