feat: fold pod controls into workspace body

This commit is contained in:
Yahya Alhinai
2026-06-28 05:14:08 +00:00
parent aeb75c310e
commit dc937a783d
2 changed files with 292 additions and 310 deletions
+269 -295
View File
@@ -22,12 +22,7 @@ import {
XIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client'; import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
import type { import type { Pod, PodActivityEvent, PodActivityKind, PodActivitySource } from '@podman/shared';
Pod,
PodActivityEvent,
PodActivityKind,
PodActivitySource,
} from '@podman/shared';
import { startBeat, type BeatHandle } from '../lib/beat.js'; import { startBeat, type BeatHandle } from '../lib/beat.js';
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js'; import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
import { usePodActivity } from '../hooks/use-pod-activity.js'; import { usePodActivity } from '../hooks/use-pod-activity.js';
@@ -66,7 +61,6 @@ import { cn } from '@/lib/utils';
const STREAM_SIDEBAR_WIDTH = 'clamp(20rem, 22vw, 23rem)'; const STREAM_SIDEBAR_WIDTH = 'clamp(20rem, 22vw, 23rem)';
const STREAM_RAIL_WIDTH = '4rem'; const STREAM_RAIL_WIDTH = '4rem';
const POD_TOP_BAR_HEIGHT = '5rem';
interface PInfo { interface PInfo {
id: string; id: string;
@@ -266,301 +260,289 @@ export function PodView({
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman'); const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
return ( return (
<div <SidebarProvider
style={{ '--pod-topbar-height': POD_TOP_BAR_HEIGHT } as CSSProperties} open={leftStreamOpen}
onOpenChange={setLeftStreamOpen}
style={
{
'--sidebar-width': STREAM_SIDEBAR_WIDTH,
'--sidebar-width-icon': STREAM_RAIL_WIDTH,
} as CSSProperties
}
className="min-h-screen bg-background text-foreground" className="min-h-screen bg-background text-foreground"
> >
<header <ActivitySidebar
data-testid="pod-topbar" side="left"
className="sticky top-0 z-30 border-b bg-background/90 backdrop-blur-xl" title="My stream"
> collapsedLabel="Mine"
<div className="mx-auto flex min-h-20 w-full max-w-[1480px] flex-col gap-3 px-4 py-3 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8"> testId="my-stream-sidebar"
<div className="flex min-w-0 items-center gap-3"> description={`${me}'s live screen, git, intervention, and conflict log.`}
<Tooltip> events={activity.mine}
<TooltipTrigger asChild> connected={activity.connected}
<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'} className="rounded-md">
{room ? 'live' : 'local'}
</Badge>
</div>
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<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>
</div>
</header>
<SidebarProvider
open={leftStreamOpen} open={leftStreamOpen}
onOpenChange={setLeftStreamOpen} 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={ style={
{ {
'--sidebar-width': STREAM_SIDEBAR_WIDTH, '--sidebar-width': STREAM_SIDEBAR_WIDTH,
'--sidebar-width-icon': STREAM_RAIL_WIDTH, '--sidebar-width-icon': STREAM_RAIL_WIDTH,
minHeight: 'calc(100svh - var(--pod-topbar-height))',
} as CSSProperties } as CSSProperties
} }
className="bg-background text-foreground" className="flex-1"
> >
<ActivitySidebar <SidebarInset
side="left" data-testid="pod-main-workspace"
title="My stream" className="min-h-screen min-w-0 bg-background"
collapsedLabel="Mine"
testId="my-stream-sidebar"
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': STREAM_SIDEBAR_WIDTH,
'--sidebar-width-icon': STREAM_RAIL_WIDTH,
} as CSSProperties
}
className="flex-1"
> >
<SidebarInset <div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col gap-5 px-4 py-5 sm:px-6 lg:px-8">
data-testid="pod-main-workspace" <section data-testid="pod-body-summary" className="flex flex-col gap-4">
style={{ minHeight: 'calc(100svh - var(--pod-topbar-height))' }} <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
className="min-w-0 bg-background" <div className="flex min-w-0 items-center gap-3">
> <Tooltip>
<div <TooltipTrigger asChild>
style={{ minHeight: 'calc(100svh - var(--pod-topbar-height))' }} <Button variant="ghost" size="icon" onClick={onLeave}>
className="mx-auto flex w-full max-w-[1120px] flex-col gap-5 px-4 py-5 sm:px-6 lg:px-8" <ArrowLeftIcon />
> <span className="sr-only">Leave pod</span>
{devMode && ( </Button>
<Alert> </TooltipTrigger>
<RadioTowerIcon /> <TooltipContent>Leave pod</TooltipContent>
<AlertTitle>Local mode</AlertTitle> </Tooltip>
<AlertDescription>LiveKit is not configured for this session.</AlertDescription> <div className="grid size-10 shrink-0 place-items-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm">
</Alert> {initials(team.name) || 'PM'}
)} </div>
<div className="min-w-0">
{note && ( <div className="flex flex-wrap items-center gap-2">
<Alert> <h1 className="text-[1.75rem] font-semibold leading-none tracking-tight">
<CircleDotIcon /> {team.name}
<AlertTitle>Room notice</AlertTitle> </h1>
<AlertDescription>{note}</AlertDescription> <Badge variant={room ? 'default' : 'secondary'} className="rounded-md">
</Alert> {room ? 'live' : 'local'}
)} </Badge>
{room && audioBlocked && (
<Alert className="flex items-center justify-between gap-3">
<div className="flex items-start gap-3">
<Volume2Icon />
<div>
<AlertTitle>Sound is off</AlertTitle>
<AlertDescription>Click to hear PodMan&apos;s voice alerts.</AlertDescription>
</div> </div>
<p className="truncate font-mono text-xs text-muted-foreground">{team.repo}</p>
</div> </div>
<Button size="sm" onClick={() => void enableSound()}> </div>
Enable sound
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button variant="outline" onClick={toggleBeat} disabled={!room}>
<Volume2Icon data-icon="inline-start" />
{playingBeat ? 'Stop audio' : 'Test audio'}
</Button> </Button>
</Alert> <Button onClick={toggleScreen} disabled={!room}>
)} <MonitorUpIcon data-icon="inline-start" />
{sharing ? 'Stop sharing' : 'Share screen'}
</Button>
</div>
</div>
<main className="grid flex-1 gap-5 min-[1800px]:grid-cols-[minmax(0,1fr)_340px]"> <div className="grid gap-3 sm:grid-cols-3">
<section className="flex min-w-0 flex-col gap-5"> <Metric label="Participants" value={participants.length || 1} />
<div className="grid gap-3 sm:grid-cols-3"> <Metric label="Screen" value={sharing ? 'sharing' : 'idle'} />
<Metric label="Participants" value={participants.length || 1} /> <Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
<Metric label="Screen" value={sharing ? 'sharing' : 'idle'} /> </div>
<Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} /> </section>
{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>
)}
{room && audioBlocked && (
<Alert className="flex items-center justify-between gap-3">
<div className="flex items-start gap-3">
<Volume2Icon />
<div>
<AlertTitle>Sound is off</AlertTitle>
<AlertDescription>Click to hear PodMan&apos;s voice alerts.</AlertDescription>
</div> </div>
</div>
<Button size="sm" onClick={() => void enableSound()}>
Enable sound
</Button>
</Alert>
)}
<Card className="flex-1"> <main className="grid flex-1 gap-5 min-[1800px]:grid-cols-[minmax(0,1fr)_340px]">
<CardHeader> <section className="flex min-w-0 flex-col gap-5">
<CardTitle>Room state</CardTitle> <Card className="flex-1">
<CardDescription> <CardHeader>
People and media currently visible to PodMan. <CardTitle>Room state</CardTitle>
</CardDescription> <CardDescription>People and media currently visible to PodMan.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{participants.length === 0 ? ( {participants.length === 0 ? (
<Empty className="min-h-80"> <Empty className="min-h-80">
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<RadioTowerIcon /> <RadioTowerIcon />
</EmptyMedia> </EmptyMedia>
<EmptyTitle>Connecting</EmptyTitle> <EmptyTitle>Connecting</EmptyTitle>
<EmptyDescription>Waiting for LiveKit room state.</EmptyDescription> <EmptyDescription>Waiting for LiveKit room state.</EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
) : ( ) : (
<div className="grid gap-2 md:grid-cols-2"> <div className="grid gap-2 md:grid-cols-2">
{participants.map((p) => ( {participants.map((p) => (
<Participant key={p.id} participant={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> </div>
</CardContent> )}
</Card> </CardContent>
</aside> <CardFooter>
</main> <p className="truncate text-sm text-muted-foreground">
<div ref={audioRef} className="hidden" /> Roster: {team.members.join(', ') || 'No saved members'}
</div> </p>
</SidebarInset> </CardFooter>
<ActivitySidebar </Card>
side="right"
title="Team stream" {activity.error && (
collapsedLabel="Team" <p className="text-xs text-muted-foreground">{activity.error}</p>
testId="team-stream-sidebar" )}
description="Everyone else in this pod, merged into one realtime feed." </section>
events={activity.team}
connected={activity.connected} <aside className="flex flex-col gap-5">
open={rightStreamOpen} <Card>
onToggle={() => setRightStreamOpen((open) => !open)} <CardHeader>
emptyTitle="No teammate signal yet" <CardTitle>Intervention</CardTitle>
emptyDescription="Waiting for other members' screen, git, or collision events." <CardDescription>Card first, voice only for urgent escalation.</CardDescription>
/> <CardAction>
</SidebarProvider> <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"
testId="team-stream-sidebar"
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>
</div> </SidebarProvider>
); );
} }
@@ -645,7 +627,7 @@ function ActivitySidebar({
<Sidebar <Sidebar
side={side} side={side}
collapsible="icon" collapsible="icon"
className="border-border/80 bg-sidebar md:!top-[var(--pod-topbar-height)] md:!h-[calc(100svh-var(--pod-topbar-height))]" className="border-border/80 bg-sidebar"
data-testid={testId} data-testid={testId}
> >
<SidebarHeader className="border-b px-3 py-3"> <SidebarHeader className="border-b px-3 py-3">
@@ -709,9 +691,7 @@ function ActivitySidebar({
{events.length ? ( {events.length ? (
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-4 overflow-y-auto pr-1"> <div className="flex max-h-[calc(100svh-9rem)] flex-col gap-4 overflow-y-auto pr-1">
{ACTIVITY_CATEGORIES.map((category) => { {ACTIVITY_CATEGORIES.map((category) => {
const items = events.filter( const items = events.filter((event) => CATEGORY_OF[event.kind] === category.id);
(event) => CATEGORY_OF[event.kind] === category.id,
);
if (!items.length) return null; if (!items.length) return null;
const CategoryIcon = category.icon; const CategoryIcon = category.icon;
return ( return (
@@ -721,10 +701,7 @@ function ActivitySidebar({
<h3 className="text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
{category.label} {category.label}
</h3> </h3>
<Badge <Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[0.65rem]">
variant="secondary"
className="rounded-md px-1.5 py-0 text-[0.65rem]"
>
{items.length} {items.length}
</Badge> </Badge>
</div> </div>
@@ -829,10 +806,7 @@ function ActivityItem({ event }: { event: PodActivityEvent }) {
)} )}
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5"> <div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<SourceChip source={event.source} /> <SourceChip source={event.source} />
<Badge <Badge variant="outline" className="rounded-md px-1.5 py-0 text-[0.68rem] leading-4">
variant="outline"
className="rounded-md px-1.5 py-0 text-[0.68rem] leading-4"
>
{KIND_LABEL[event.kind]} {KIND_LABEL[event.kind]}
</Badge> </Badge>
{metadata.map((item, index) => ( {metadata.map((item, index) => (
+23 -15
View File
@@ -186,9 +186,9 @@ function assertNoOverlap(left, main, right, label) {
} }
} }
function assertStableTopbar(before, after, label) { function assertContained(container, child, label) {
if (Math.abs(before.x - after.x) > 2 || Math.abs(before.width - after.width) > 2) { if (child.x < container.x - 2 || child.x + child.width > container.x + container.width + 2) {
throw new Error(`${label}: top bar moved or resized with sidebar state`); throw new Error(`${label}: body summary is not contained inside the main workspace`);
} }
} }
@@ -268,12 +268,16 @@ try {
if (!hasPodCards) throw new Error('pod cards did not render'); if (!hasPodCards) throw new Error('pod cards did not render');
if (hasOverlay) throw new Error('Vite error overlay is visible'); if (hasOverlay) throw new Error('Vite error overlay is visible');
await page.getByRole('button', { name: 'Team memory' }).click(); await page.getByRole('button', { name: 'Pod actions' }).first().click();
await page.getByRole('menuitem', { name: 'Team memory' }).click();
await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 }); await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
await page.getByText('Learning edges').waitFor({ timeout: 15_000 }); await page.getByText('Learning edges').waitFor({ timeout: 15_000 });
await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 }); await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'engineer: Karti' }).click(); await page
await page.getByText('Learned owner of auth; backend + DB wiring.').waitFor({ timeout: 15_000 }); .getByRole('button', { name: /engineer:/ })
.first()
.click();
await page.getByText('Relationships').waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Whole graph' }).click(); await page.getByRole('button', { name: 'Whole graph' }).click();
await page.getByRole('button', { name: /Pods/i }).click(); await page.getByRole('button', { name: /Pods/i }).click();
@@ -288,19 +292,22 @@ try {
} }
await page.getByRole('heading', { name: 'My stream' }).waitFor({ timeout: 15_000 }); await page.getByRole('heading', { name: 'My stream' }).waitFor({ timeout: 15_000 });
await page.getByRole('heading', { name: 'Team stream' }).waitFor({ timeout: 15_000 }); await page.getByRole('heading', { name: 'Team stream' }).waitFor({ timeout: 15_000 });
const topbar = page.getByTestId('pod-topbar'); const bodySummary = page.getByTestId('pod-body-summary');
const mainWorkspace = page.getByTestId('pod-main-workspace'); const mainWorkspace = page.getByTestId('pod-main-workspace');
const mySidebar = page.getByTestId('my-stream-sidebar'); const mySidebar = page.getByTestId('my-stream-sidebar');
const teamSidebar = page.getByTestId('team-stream-sidebar'); const teamSidebar = page.getByTestId('team-stream-sidebar');
await topbar.waitFor({ timeout: 15_000 }); await bodySummary.waitFor({ timeout: 15_000 });
await mainWorkspace.waitFor({ timeout: 15_000 }); await mainWorkspace.waitFor({ timeout: 15_000 });
await mySidebar.waitFor({ timeout: 15_000 }); await mySidebar.waitFor({ timeout: 15_000 });
await teamSidebar.waitFor({ timeout: 15_000 }); await teamSidebar.waitFor({ timeout: 15_000 });
if ((await topbar.getByRole('button', { name: /stream|team/i }).count()) > 0) { if ((await page.getByTestId('pod-topbar').count()) > 0) {
throw new Error('pod top bar contains sidebar stream/team controls'); throw new Error('pod detail view still renders a topbar test id');
}
if ((await bodySummary.getByRole('button', { name: /stream|team/i }).count()) > 0) {
throw new Error('pod body summary contains sidebar stream/team controls');
} }
const expandedLayout = { const expandedLayout = {
topbar: await boxOf(topbar, 'top bar expanded'), summary: await boxOf(bodySummary, 'body summary expanded'),
main: await boxOf(mainWorkspace, 'main workspace expanded'), main: await boxOf(mainWorkspace, 'main workspace expanded'),
left: await boxOf(mySidebar, 'my stream sidebar expanded'), left: await boxOf(mySidebar, 'my stream sidebar expanded'),
right: await boxOf(teamSidebar, 'team stream sidebar expanded'), right: await boxOf(teamSidebar, 'team stream sidebar expanded'),
@@ -311,13 +318,14 @@ try {
expandedLayout.right, expandedLayout.right,
'expanded layout', 'expanded layout',
); );
assertContained(expandedLayout.main, expandedLayout.summary, 'expanded layout');
await page.locator('[data-testid="my-stream-toggle"]:visible').click(); await page.locator('[data-testid="my-stream-toggle"]:visible').click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const leftCollapsedLayout = { const leftCollapsedLayout = {
main: await boxOf(mainWorkspace, 'main workspace after left collapse'), main: await boxOf(mainWorkspace, 'main workspace after left collapse'),
left: await boxOf(mySidebar, 'my stream sidebar collapsed'), left: await boxOf(mySidebar, 'my stream sidebar collapsed'),
right: await boxOf(teamSidebar, 'team stream sidebar with left collapsed'), right: await boxOf(teamSidebar, 'team stream sidebar with left collapsed'),
topbar: await boxOf(topbar, 'top bar after left collapse'), summary: await boxOf(bodySummary, 'body summary after left collapse'),
}; };
if (leftCollapsedLayout.left.width >= expandedLayout.left.width - 24) { if (leftCollapsedLayout.left.width >= expandedLayout.left.width - 24) {
throw new Error('my stream sidebar did not collapse into a compact rail'); throw new Error('my stream sidebar did not collapse into a compact rail');
@@ -331,14 +339,14 @@ try {
leftCollapsedLayout.right, leftCollapsedLayout.right,
'left collapsed layout', 'left collapsed layout',
); );
assertStableTopbar(expandedLayout.topbar, leftCollapsedLayout.topbar, 'left collapsed layout'); assertContained(leftCollapsedLayout.main, leftCollapsedLayout.summary, 'left collapsed layout');
await page.locator('[data-testid="team-stream-toggle"]:visible').click(); await page.locator('[data-testid="team-stream-toggle"]:visible').click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const bothCollapsedLayout = { const bothCollapsedLayout = {
main: await boxOf(mainWorkspace, 'main workspace after both collapse'), main: await boxOf(mainWorkspace, 'main workspace after both collapse'),
left: await boxOf(mySidebar, 'my stream sidebar with both collapsed'), left: await boxOf(mySidebar, 'my stream sidebar with both collapsed'),
right: await boxOf(teamSidebar, 'team stream sidebar collapsed'), right: await boxOf(teamSidebar, 'team stream sidebar collapsed'),
topbar: await boxOf(topbar, 'top bar after both collapse'), summary: await boxOf(bodySummary, 'body summary after both collapse'),
}; };
if (bothCollapsedLayout.right.width >= expandedLayout.right.width - 24) { if (bothCollapsedLayout.right.width >= expandedLayout.right.width - 24) {
throw new Error('team stream sidebar did not collapse into a compact rail'); throw new Error('team stream sidebar did not collapse into a compact rail');
@@ -352,7 +360,7 @@ try {
bothCollapsedLayout.right, bothCollapsedLayout.right,
'both collapsed layout', 'both collapsed layout',
); );
assertStableTopbar(expandedLayout.topbar, bothCollapsedLayout.topbar, 'both collapsed layout'); assertContained(bothCollapsedLayout.main, bothCollapsedLayout.summary, 'both collapsed layout');
await page.locator('[data-testid="my-stream-toggle"]:visible').click(); await page.locator('[data-testid="my-stream-toggle"]:visible').click();
await page.locator('[data-testid="team-stream-toggle"]:visible').click(); await page.locator('[data-testid="team-stream-toggle"]:visible').click();
await page.waitForTimeout(300); await page.waitForTimeout(300);