feat: fold pod controls into workspace body
This commit is contained in:
+269
-295
@@ -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'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'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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user