1660 lines
59 KiB
TypeScript
1660 lines
59 KiB
TypeScript
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
|
import { Room as LiveKitRoom, RoomEvent, Track } from 'livekit-client';
|
|
import {
|
|
ArrowLeftIcon,
|
|
BarChart3Icon,
|
|
BrainIcon,
|
|
CheckIcon,
|
|
CircleDotIcon,
|
|
EyeIcon,
|
|
GitBranchIcon,
|
|
ExternalLinkIcon,
|
|
MessageSquareIcon,
|
|
MicIcon,
|
|
MicOffIcon,
|
|
MonitorUpIcon,
|
|
PhoneCallIcon,
|
|
PhoneOffIcon,
|
|
PanelLeftIcon,
|
|
PanelRightIcon,
|
|
RadioTowerIcon,
|
|
ShieldIcon,
|
|
SparklesIcon,
|
|
TriangleAlertIcon,
|
|
Volume2Icon,
|
|
VolumeXIcon,
|
|
WorkflowIcon,
|
|
XIcon,
|
|
} from 'lucide-react';
|
|
import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client';
|
|
import type {
|
|
HermesJob,
|
|
HermesJobEvent,
|
|
MemberWorkHistory,
|
|
MemberWorkHistoryEvent,
|
|
MemberWorkHistoryFile,
|
|
Pod,
|
|
PodActivityEvent,
|
|
PodActivityKind,
|
|
PodActivitySource,
|
|
} from '@podman/shared';
|
|
import { useBeat } from '../livekit/useBeat.js';
|
|
import {
|
|
abortLiveConversationHermesJob,
|
|
getLiveConversationHermesJob,
|
|
getMemberWorkHistory,
|
|
startLiveConversation,
|
|
stopLiveConversation,
|
|
testPodVoice,
|
|
type LiveConversationSession,
|
|
} from '../lib/api.js';
|
|
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
|
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
|
import LiveWaveform from '@/components/ruixen/live-waveform';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from '@/components/ui/empty';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
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 { cn } from '@/lib/utils';
|
|
|
|
const STREAM_SIDEBAR_WIDTH = 'clamp(20rem, 22vw, 23rem)';
|
|
const STREAM_RAIL_WIDTH = '4rem';
|
|
|
|
interface PInfo {
|
|
id: string;
|
|
name: string;
|
|
isLocal: boolean;
|
|
speaking: boolean;
|
|
}
|
|
|
|
function snapshot(room: Room, fallbackName: string): PInfo[] {
|
|
const lp = room.localParticipant;
|
|
const local: PInfo = {
|
|
id: lp.identity,
|
|
name: lp.name || fallbackName,
|
|
isLocal: true,
|
|
speaking: lp.isSpeaking,
|
|
};
|
|
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({
|
|
id: p.identity,
|
|
name: p.name || p.identity,
|
|
isLocal: false,
|
|
speaking: p.isSpeaking,
|
|
}));
|
|
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({
|
|
team,
|
|
me,
|
|
room,
|
|
devMode,
|
|
onLeave,
|
|
}: {
|
|
team: Pod;
|
|
me: string;
|
|
room: Room | null;
|
|
devMode: boolean;
|
|
onLeave: () => void;
|
|
}) {
|
|
const [participants, setParticipants] = useState<PInfo[]>([]);
|
|
const [sharing, setSharing] = useState(false);
|
|
const [testingVoice, setTestingVoice] = useState(false);
|
|
const [note, setNote] = useState<string | null>(null);
|
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
const [remoteAudioTracks, setRemoteAudioTracks] = useState(0);
|
|
const [micOn, setMicOn] = useState(false);
|
|
const [historyMember, setHistoryMember] = useState<string | null>(null);
|
|
const [history, setHistory] = useState<MemberWorkHistory | null>(null);
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
|
const [conversationRoom, setConversationRoom] = useState<Room | null>(null);
|
|
const [conversationSession, setConversationSession] = useState<LiveConversationSession | null>(
|
|
null,
|
|
);
|
|
const [conversationState, setConversationState] = useState<
|
|
'idle' | 'connecting' | 'listening' | 'speaking' | 'interrupted' | 'error'
|
|
>('idle');
|
|
const [conversationNote, setConversationNote] = useState<string | null>(null);
|
|
const [hermesJob, setHermesJob] = useState<HermesJob | null>(null);
|
|
const [hermesJobEvents, setHermesJobEvents] = useState<HermesJobEvent[]>([]);
|
|
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 { beat, toggleBeat: runBeat } = useBeat(room);
|
|
const activity = usePodActivity(team.id, me);
|
|
|
|
const audioRef = useRef<HTMLDivElement>(null);
|
|
const audioElementsRef = useRef(new Map<string, HTMLElement>());
|
|
const conversationAudioElementsRef = useRef(new Map<string, HTMLElement>());
|
|
const screenTrackRef = useRef<MediaStreamTrack | null>(null);
|
|
const onLeaveRef = useRef(onLeave);
|
|
onLeaveRef.current = onLeave;
|
|
|
|
useEffect(() => {
|
|
if (!room) return;
|
|
const refresh = () => setParticipants(snapshot(room, me));
|
|
refresh();
|
|
|
|
const attachAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => {
|
|
if (track.kind !== Track.Kind.Audio || !audioRef.current) return;
|
|
const key = pub.trackSid || track.sid || track.mediaStreamTrack.id;
|
|
if (audioElementsRef.current.has(key)) return;
|
|
const element = track.attach();
|
|
element.autoplay = true;
|
|
audioElementsRef.current.set(key, element);
|
|
audioRef.current.appendChild(element);
|
|
setRemoteAudioTracks(audioElementsRef.current.size);
|
|
};
|
|
const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => {
|
|
const key = pub?.trackSid || track.sid || track.mediaStreamTrack.id;
|
|
const attached = audioElementsRef.current.get(key);
|
|
if (attached) {
|
|
attached.remove();
|
|
audioElementsRef.current.delete(key);
|
|
setRemoteAudioTracks(audioElementsRef.current.size);
|
|
}
|
|
track.detach().forEach((el) => el.remove());
|
|
};
|
|
const attachExistingAudio = () => {
|
|
room.remoteParticipants.forEach((participant) => {
|
|
participant.audioTrackPublications.forEach((publication) => {
|
|
const track = publication.track;
|
|
if (track) attachAudio(track, publication);
|
|
});
|
|
});
|
|
};
|
|
const onAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => attachAudio(track, pub);
|
|
const onAudioGone = (track: RemoteTrack, pub: RemoteTrackPublication) =>
|
|
removeAudio(track, pub);
|
|
const onDisconnected = () => onLeaveRef.current();
|
|
// Browsers block autoplay of incoming audio until a user gesture unlocks it.
|
|
// Surface a button whenever the room can't play sound so PodMan's voice cues
|
|
// are actually heard.
|
|
const onPlaybackChanged = () => setAudioBlocked(!room.canPlaybackAudio);
|
|
const unlockAudioFromGesture = () => {
|
|
void room.startAudio().finally(() => setAudioBlocked(!room.canPlaybackAudio));
|
|
};
|
|
onPlaybackChanged();
|
|
setMicOn(room.localParticipant.isMicrophoneEnabled);
|
|
window.addEventListener('pointerdown', unlockAudioFromGesture, {
|
|
once: true,
|
|
capture: true,
|
|
});
|
|
window.addEventListener('keydown', unlockAudioFromGesture, { once: true, capture: true });
|
|
|
|
room
|
|
.on(RoomEvent.ParticipantConnected, refresh)
|
|
.on(RoomEvent.ParticipantDisconnected, refresh)
|
|
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
|
.on(RoomEvent.TrackSubscribed, onAudio)
|
|
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
|
.on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
|
.on(RoomEvent.Disconnected, onDisconnected);
|
|
attachExistingAudio();
|
|
|
|
return () => {
|
|
room
|
|
.off(RoomEvent.ParticipantConnected, refresh)
|
|
.off(RoomEvent.ParticipantDisconnected, refresh)
|
|
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
|
.off(RoomEvent.TrackSubscribed, onAudio)
|
|
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
|
.off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
|
.off(RoomEvent.Disconnected, onDisconnected);
|
|
window.removeEventListener('pointerdown', unlockAudioFromGesture, { capture: true });
|
|
window.removeEventListener('keydown', unlockAudioFromGesture, { capture: true });
|
|
audioElementsRef.current.forEach((el) => el.remove());
|
|
audioElementsRef.current.clear();
|
|
setRemoteAudioTracks(0);
|
|
};
|
|
}, [room, me]);
|
|
|
|
useEffect(() => {
|
|
if (!conversationRoom) return;
|
|
const attachAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => {
|
|
if (track.kind !== Track.Kind.Audio || !audioRef.current) return;
|
|
const key = `conversation:${pub.trackSid || track.sid || track.mediaStreamTrack.id}`;
|
|
if (conversationAudioElementsRef.current.has(key)) return;
|
|
const element = track.attach();
|
|
element.autoplay = true;
|
|
conversationAudioElementsRef.current.set(key, element);
|
|
audioRef.current.appendChild(element);
|
|
setRemoteAudioTracks(
|
|
audioElementsRef.current.size + conversationAudioElementsRef.current.size,
|
|
);
|
|
};
|
|
const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => {
|
|
const key = `conversation:${pub?.trackSid || track.sid || track.mediaStreamTrack.id}`;
|
|
const attached = conversationAudioElementsRef.current.get(key);
|
|
if (attached) {
|
|
attached.remove();
|
|
conversationAudioElementsRef.current.delete(key);
|
|
setRemoteAudioTracks(
|
|
audioElementsRef.current.size + conversationAudioElementsRef.current.size,
|
|
);
|
|
}
|
|
track.detach().forEach((el) => el.remove());
|
|
};
|
|
const refreshState = () => {
|
|
const agentSpeaking = Array.from(conversationRoom.remoteParticipants.values()).some(
|
|
(participant) => participant.isSpeaking,
|
|
);
|
|
setConversationState((current) =>
|
|
current === 'connecting' || current === 'error'
|
|
? current
|
|
: agentSpeaking
|
|
? 'speaking'
|
|
: 'listening',
|
|
);
|
|
};
|
|
const onData = (payload: Uint8Array) => {
|
|
try {
|
|
const msg = JSON.parse(new TextDecoder().decode(payload)) as {
|
|
type?: string;
|
|
event?: { interrupt?: boolean; summary?: string };
|
|
};
|
|
if (msg.type === 'LIVE_CONVERSATION_EVENT') {
|
|
setConversationState(msg.event?.interrupt ? 'interrupted' : 'listening');
|
|
if (msg.event?.summary) setConversationNote(msg.event.summary);
|
|
}
|
|
if (msg.type === 'HERMES_JOB_EVENT') {
|
|
const event = msg.event as HermesJobEvent;
|
|
setHermesJobEvents((events) =>
|
|
[...events.filter((item) => item.id !== event.id), event].slice(-12),
|
|
);
|
|
setConversationNote(event.message);
|
|
}
|
|
} catch {
|
|
// Ignore non-PodMan private-room data.
|
|
}
|
|
};
|
|
|
|
conversationRoom
|
|
.on(RoomEvent.TrackSubscribed, attachAudio)
|
|
.on(RoomEvent.TrackUnsubscribed, removeAudio)
|
|
.on(RoomEvent.ActiveSpeakersChanged, refreshState)
|
|
.on(RoomEvent.DataReceived, onData)
|
|
.on(RoomEvent.Disconnected, () => setConversationState('idle'));
|
|
refreshState();
|
|
|
|
return () => {
|
|
conversationRoom
|
|
.off(RoomEvent.TrackSubscribed, attachAudio)
|
|
.off(RoomEvent.TrackUnsubscribed, removeAudio)
|
|
.off(RoomEvent.ActiveSpeakersChanged, refreshState)
|
|
.off(RoomEvent.DataReceived, onData);
|
|
conversationAudioElementsRef.current.forEach((el) => el.remove());
|
|
conversationAudioElementsRef.current.clear();
|
|
setRemoteAudioTracks(audioElementsRef.current.size);
|
|
};
|
|
}, [conversationRoom]);
|
|
|
|
useEffect(() => {
|
|
if (!conversationSession) {
|
|
setHermesJob(null);
|
|
setHermesJobEvents([]);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
const refresh = async () => {
|
|
try {
|
|
const next = await getLiveConversationHermesJob(team.id, conversationSession.sessionId);
|
|
if (!alive) return;
|
|
setHermesJob(next.job);
|
|
setHermesJobEvents(next.events);
|
|
} catch {
|
|
// Keep voice conversation usable even if the status panel cannot refresh.
|
|
}
|
|
};
|
|
void refresh();
|
|
const interval = setInterval(() => void refresh(), 2500);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(interval);
|
|
};
|
|
}, [team.id, conversationSession]);
|
|
|
|
useEffect(() => {
|
|
if (!historyMember) {
|
|
setHistory(null);
|
|
setHistoryError(null);
|
|
setHistoryLoading(false);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
setHistory(null);
|
|
setHistoryError(null);
|
|
setHistoryLoading(true);
|
|
getMemberWorkHistory(team.id, historyMember)
|
|
.then((next) => {
|
|
if (alive) setHistory(next);
|
|
})
|
|
.catch((e) => {
|
|
if (alive) setHistoryError((e as Error).message);
|
|
})
|
|
.finally(() => {
|
|
if (alive) setHistoryLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [team.id, historyMember]);
|
|
|
|
async function enableSound() {
|
|
primeSpeech(); // unlock browser voice from this gesture
|
|
if (!room) return;
|
|
try {
|
|
await room.startAudio();
|
|
setAudioBlocked(!room.canPlaybackAudio);
|
|
} catch (e) {
|
|
setNote(`Could not enable sound: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// Publish/unpublish the local mic. Enabling triggers the browser permission
|
|
// prompt, so the button doubles as a "is my mic set up right?" check.
|
|
async function toggleMic() {
|
|
if (!room) return;
|
|
setNote(null);
|
|
try {
|
|
const next = !micOn;
|
|
await room.localParticipant.setMicrophoneEnabled(next);
|
|
setMicOn(next);
|
|
} catch (e) {
|
|
setNote(`Could not toggle mic: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
screenTrackRef.current?.stop();
|
|
screenTrackRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
void conversationRoom?.disconnect();
|
|
};
|
|
}, [conversationRoom]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('podman.myStreamOpen', String(leftStreamOpen));
|
|
}, [leftStreamOpen]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('podman.teamStreamOpen', String(rightStreamOpen));
|
|
}, [rightStreamOpen]);
|
|
|
|
async function onToggleBeat() {
|
|
setNote(null);
|
|
try {
|
|
await runBeat();
|
|
} catch (e) {
|
|
setNote(`Audio test failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function playPodManVoiceTest() {
|
|
if (!room) return;
|
|
setNote(null);
|
|
setTestingVoice(true);
|
|
try {
|
|
await room.startAudio().catch(() => {});
|
|
setAudioBlocked(!room.canPlaybackAudio);
|
|
await testPodVoice(team.id);
|
|
setNote('PodMan voice test sent.');
|
|
} catch (e) {
|
|
setNote(`PodMan voice test failed: ${(e as Error).message}`);
|
|
} finally {
|
|
setTestingVoice(false);
|
|
}
|
|
}
|
|
|
|
async function toggleLiveConversation() {
|
|
setNote(null);
|
|
setConversationNote(null);
|
|
if (conversationRoom && conversationSession) {
|
|
const endingRoom = conversationRoom;
|
|
const endingSession = conversationSession;
|
|
setConversationRoom(null);
|
|
setConversationSession(null);
|
|
setConversationState('idle');
|
|
setHermesJob(null);
|
|
setHermesJobEvents([]);
|
|
try {
|
|
await endingRoom.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
|
await endingRoom.disconnect();
|
|
await stopLiveConversation(team.id, endingSession.sessionId).catch(() => {});
|
|
} catch (e) {
|
|
setNote(`Could not stop live conversation: ${(e as Error).message}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setConversationState('connecting');
|
|
try {
|
|
primeSpeech();
|
|
await room?.startAudio().catch(() => {});
|
|
const session = await startLiveConversation(team.id, { identity: me, displayName: me });
|
|
const privateRoom = new LiveKitRoom({ adaptiveStream: true, dynacast: true });
|
|
privateRoom.on(RoomEvent.Disconnected, () => {
|
|
setConversationRoom(null);
|
|
setConversationSession(null);
|
|
setConversationState('idle');
|
|
});
|
|
await privateRoom.connect(session.url, session.token);
|
|
await privateRoom.startAudio().catch(() => {});
|
|
await privateRoom.localParticipant.setMicrophoneEnabled(true);
|
|
setConversationSession(session);
|
|
setConversationRoom(privateRoom);
|
|
setConversationState('listening');
|
|
} catch (e) {
|
|
setConversationState('error');
|
|
setConversationRoom(null);
|
|
setConversationSession(null);
|
|
setHermesJob(null);
|
|
setHermesJobEvents([]);
|
|
setNote(`Live conversation failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function stopHermesJob() {
|
|
if (!conversationSession || !hermesJob) return;
|
|
setNote(null);
|
|
try {
|
|
const next = await abortLiveConversationHermesJob(team.id, conversationSession.sessionId);
|
|
setHermesJob(next.job);
|
|
setConversationNote('Hermes is aborting the current job.');
|
|
} catch (e) {
|
|
setNote(`Could not stop Hermes job: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function toggleScreen() {
|
|
primeSpeech(); // unlock browser voice from this gesture too
|
|
if (!room) return;
|
|
setNote(null);
|
|
try {
|
|
await room.startAudio().catch(() => {});
|
|
setAudioBlocked(!room.canPlaybackAudio);
|
|
if (sharing) {
|
|
if (screenTrackRef.current)
|
|
await room.localParticipant.unpublishTrack(screenTrackRef.current);
|
|
screenTrackRef.current?.stop();
|
|
screenTrackRef.current = null;
|
|
setSharing(false);
|
|
return;
|
|
}
|
|
if (!window.isSecureContext || !navigator.mediaDevices?.getDisplayMedia) {
|
|
setNote('Screen capture needs HTTPS.');
|
|
return;
|
|
}
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
const track = stream.getVideoTracks()[0];
|
|
if (!track) return;
|
|
track.onended = () => {
|
|
screenTrackRef.current = null;
|
|
setSharing(false);
|
|
};
|
|
await room.localParticipant.publishTrack(track, { source: Track.Source.ScreenShare });
|
|
screenTrackRef.current = track;
|
|
setSharing(true);
|
|
} catch (e) {
|
|
setNote(`Screen share stopped: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function answerIntervention(status: 'accepted' | 'dismissed', accepted: boolean) {
|
|
setNote(null);
|
|
try {
|
|
await respond(status, accepted);
|
|
} catch (e) {
|
|
setNote(`Action failed: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
|
|
|
return (
|
|
<SidebarProvider
|
|
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"
|
|
>
|
|
<ActivitySidebar
|
|
side="left"
|
|
title="My stream"
|
|
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
|
|
data-testid="pod-main-workspace"
|
|
className="min-h-screen min-w-0 bg-background"
|
|
>
|
|
<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">
|
|
<section data-testid="pod-body-summary" className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" onClick={onLeave}>
|
|
<ArrowLeftIcon />
|
|
<span className="sr-only">Leave pod</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Leave pod</TooltipContent>
|
|
</Tooltip>
|
|
<div className="grid size-10 shrink-0 place-items-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm">
|
|
{initials(team.name) || 'PM'}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h1 className="text-[1.75rem] font-semibold leading-none 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:flex-wrap sm:items-center">
|
|
<Button
|
|
variant={audioBlocked ? 'default' : 'outline'}
|
|
onClick={() => void enableSound()}
|
|
disabled={!room}
|
|
>
|
|
{audioBlocked ? (
|
|
<VolumeXIcon data-icon="inline-start" />
|
|
) : (
|
|
<Volume2Icon data-icon="inline-start" />
|
|
)}
|
|
{audioBlocked ? 'Enable audio' : 'Audio on'}
|
|
</Button>
|
|
<Button
|
|
variant={micOn ? 'default' : 'outline'}
|
|
onClick={() => void toggleMic()}
|
|
disabled={!room}
|
|
>
|
|
{micOn ? (
|
|
<MicIcon data-icon="inline-start" />
|
|
) : (
|
|
<MicOffIcon data-icon="inline-start" />
|
|
)}
|
|
{micOn ? 'Mic on' : 'Enable mic'}
|
|
</Button>
|
|
<Button variant="outline" onClick={onToggleBeat} disabled={!room}>
|
|
<Volume2Icon data-icon="inline-start" />
|
|
{beat.on ? (beat.mine ? 'Stop audio' : `Stop (${beat.by})`) : 'Test audio'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void playPodManVoiceTest()}
|
|
disabled={!room || testingVoice}
|
|
>
|
|
<RadioTowerIcon data-icon="inline-start" />
|
|
{testingVoice ? 'Sending voice' : 'Test PodMan voice'}
|
|
</Button>
|
|
<Button onClick={toggleScreen} disabled={!room}>
|
|
<MonitorUpIcon data-icon="inline-start" />
|
|
{sharing ? 'Stop sharing' : 'Share screen'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</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>
|
|
)}
|
|
|
|
<main className="grid flex-1 gap-5 min-[1800px]:grid-cols-[minmax(0,1fr)_340px]">
|
|
<section className="flex min-w-0 flex-col gap-5">
|
|
<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}
|
|
onOpenHistory={setHistoryMember}
|
|
/>
|
|
))}
|
|
</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>Live Conversation</CardTitle>
|
|
<CardDescription>Private voice channel with PodMan context.</CardDescription>
|
|
<CardAction>
|
|
<Badge
|
|
variant={conversationRoom ? 'default' : 'secondary'}
|
|
className="rounded-md"
|
|
>
|
|
{conversationRoom ? 'live' : 'off'}
|
|
</Badge>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<Button
|
|
onClick={() => void toggleLiveConversation()}
|
|
disabled={!room || conversationState === 'connecting'}
|
|
variant={conversationRoom ? 'outline' : 'default'}
|
|
data-testid="live-conversation-toggle"
|
|
>
|
|
{conversationRoom ? (
|
|
<PhoneOffIcon data-icon="inline-start" />
|
|
) : (
|
|
<PhoneCallIcon data-icon="inline-start" />
|
|
)}
|
|
{conversationState === 'connecting'
|
|
? 'Connecting'
|
|
: conversationRoom
|
|
? 'Stop Live Conversation'
|
|
: 'Start Live Conversation'}
|
|
</Button>
|
|
<div className="grid gap-2 text-sm">
|
|
<StatusLine
|
|
label="Mode"
|
|
value={conversationRoom ? 'private 1:1 room' : 'not started'}
|
|
/>
|
|
<StatusLine
|
|
label="State"
|
|
value={conversationState === 'idle' ? 'ready' : conversationState}
|
|
/>
|
|
<StatusLine
|
|
label="Context"
|
|
value={conversationRoom ? 'synced on demand' : 'waiting'}
|
|
/>
|
|
<StatusLine
|
|
label="Hermes"
|
|
value={hermesJob ? hermesJob.status.replaceAll('_', ' ') : 'idle'}
|
|
/>
|
|
</div>
|
|
{hermesJob &&
|
|
['queued', 'running', 'waiting_for_confirmation', 'aborting'].includes(
|
|
hermesJob.status,
|
|
) && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void stopHermesJob()}
|
|
data-testid="hermes-job-stop"
|
|
>
|
|
<XIcon data-icon="inline-start" />
|
|
Stop Hermes Job
|
|
</Button>
|
|
)}
|
|
{hermesJobEvents.length > 0 && (
|
|
<div className="rounded-lg border border-dashed p-3">
|
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<WorkflowIcon className="size-3.5" />
|
|
Hermes progress
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{hermesJobEvents.slice(-3).map((event) => (
|
|
<div key={event.id} className="text-sm leading-5">
|
|
<span className="font-medium">{event.type.replaceAll('_', ' ')}</span>
|
|
<span className="text-muted-foreground"> - {event.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{conversationNote && (
|
|
<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">
|
|
<TriangleAlertIcon className="size-3.5" />
|
|
Live interruption
|
|
</div>
|
|
<p className="text-sm leading-6">{conversationNote}</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Status</CardTitle>
|
|
<CardDescription>Connection, media, and agent presence.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LiveWaveform
|
|
processing={!!room || beat.on}
|
|
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={
|
|
audioBlocked
|
|
? 'blocked'
|
|
: remoteAudioTracks > 0
|
|
? `${remoteAudioTracks} remote track${remoteAudioTracks === 1 ? '' : 's'}`
|
|
: beat.on
|
|
? beat.mine
|
|
? 'publishing'
|
|
: `${beat.by} playing`
|
|
: 'ready'
|
|
}
|
|
/>
|
|
<StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
</main>
|
|
<div
|
|
ref={audioRef}
|
|
data-testid="livekit-audio-sink"
|
|
className="pointer-events-none fixed size-px overflow-hidden opacity-0"
|
|
/>
|
|
<WorkHistoryDialog
|
|
member={historyMember}
|
|
history={history}
|
|
loading={historyLoading}
|
|
error={historyError}
|
|
onOpenChange={(open) => {
|
|
if (!open) setHistoryMember(null);
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value }: { label: string; value: number | string }) {
|
|
return (
|
|
<Card size="sm">
|
|
<CardContent>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="mt-1 text-lg font-medium">{value}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Participant({
|
|
participant,
|
|
onOpenHistory,
|
|
}: {
|
|
participant: PInfo;
|
|
onOpenHistory: (member: string) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex min-h-16 items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 transition',
|
|
participant.speaking && 'bg-muted',
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Avatar>
|
|
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
|
{participant.speaking && <AvatarBadge />}
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{participant.name}</p>
|
|
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => onOpenHistory(participant.name)}
|
|
aria-label={`${participant.name} work history`}
|
|
>
|
|
<BarChart3Icon />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Work history</TooltipContent>
|
|
</Tooltip>
|
|
<Badge variant={participant.speaking ? 'default' : 'secondary'} className="rounded-md">
|
|
{participant.speaking ? 'speaking' : 'connected'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkHistoryDialog({
|
|
member,
|
|
history,
|
|
loading,
|
|
error,
|
|
onOpenChange,
|
|
}: {
|
|
member: string | null;
|
|
history: MemberWorkHistory | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
return (
|
|
<Dialog open={!!member} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[88svh] overflow-y-auto sm:max-w-[760px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{member ? `${member}'s recent work` : 'Recent work'}</DialogTitle>
|
|
<DialogDescription>
|
|
Last {history?.windowHours ?? 24} hours from MongoDB observations and git state.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{loading && (
|
|
<div className="grid min-h-72 place-items-center rounded-lg border bg-muted/20">
|
|
<div className="text-sm text-muted-foreground">Loading history</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && !loading && (
|
|
<Alert>
|
|
<TriangleAlertIcon />
|
|
<AlertTitle>History unavailable</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{history && !loading && !error && (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
<HistoryStat label="Files" value={history.totals.files} />
|
|
<HistoryStat label="Screen logs" value={history.totals.observations} />
|
|
<HistoryStat label="Git changes" value={history.totals.gitChanges} />
|
|
</div>
|
|
|
|
{history.files.length ? (
|
|
<>
|
|
<section className="rounded-lg border bg-muted/15 p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-medium">Recent files</h3>
|
|
<Badge variant="secondary" className="rounded-md">
|
|
{history.files.length}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-col gap-3">
|
|
{history.files.map((file) => (
|
|
<FileHistoryRow
|
|
key={file.file}
|
|
file={file}
|
|
max={maxFileScore(history.files)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-lg border bg-muted/15 p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-medium">Timeline</h3>
|
|
<Badge variant="outline" className="rounded-md">
|
|
{history.timeline.length}
|
|
</Badge>
|
|
</div>
|
|
<HistoryTimeline events={history.timeline} />
|
|
</section>
|
|
</>
|
|
) : (
|
|
<Empty className="min-h-72 border-0 p-0">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<BarChart3Icon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>No recent work history</EmptyTitle>
|
|
<EmptyDescription>
|
|
MongoDB has no recent screen observations or git changes for this member.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function HistoryStat({ label, value }: { label: string; value: number }) {
|
|
return (
|
|
<div className="rounded-lg border bg-background px-3 py-2">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<p className="mt-1 font-mono text-lg font-medium">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function maxFileScore(files: MemberWorkHistoryFile[]): number {
|
|
return Math.max(1, ...files.map((file) => file.observations + file.gitChanges));
|
|
}
|
|
|
|
function FileHistoryRow({ file, max }: { file: MemberWorkHistoryFile; max: number }) {
|
|
const score = file.observations + file.gitChanges;
|
|
const width = `${Math.max(8, Math.round((score / max) * 100))}%`;
|
|
return (
|
|
<div className="grid gap-2">
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-mono text-sm font-medium">{file.file}</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{file.activities[0] ?? `${timeLabel(file.lastSeenAt)} ago`}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{file.current && (
|
|
<Badge variant="default" className="rounded-md px-1.5 py-0 text-[0.68rem]">
|
|
current
|
|
</Badge>
|
|
)}
|
|
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[0.68rem]">
|
|
{score}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary"
|
|
style={{ width }}
|
|
aria-label={`${score} recent work signals`}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.68rem] text-muted-foreground">
|
|
<span>{file.observations} screen</span>
|
|
<span>{file.gitChanges} git</span>
|
|
{file.confidenceAvg !== null && <span>{Math.round(file.confidenceAvg * 100)}% conf</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HistoryTimeline({ events }: { events: MemberWorkHistoryEvent[] }) {
|
|
if (!events.length) {
|
|
return <p className="text-sm text-muted-foreground">No timeline entries.</p>;
|
|
}
|
|
return (
|
|
<div className="relative flex flex-col gap-3 pl-4 before:absolute before:left-[0.31rem] before:top-2 before:h-[calc(100%-1rem)] before:w-px before:bg-border">
|
|
{events.slice(0, 18).map((event) => {
|
|
const Icon = event.source === 'git' ? GitBranchIcon : MonitorUpIcon;
|
|
return (
|
|
<div key={event.id} className="relative grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
|
|
<span className="absolute -left-4 top-2 size-2 rounded-full bg-primary" />
|
|
<div className="mt-0.5 flex size-6 items-center justify-center rounded-md border bg-background text-muted-foreground">
|
|
<Icon className="size-3.5" />
|
|
</div>
|
|
<div className="min-w-0 rounded-md border bg-background px-3 py-2">
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
<p className="line-clamp-1 min-w-0 break-words text-sm font-medium">
|
|
{event.title}
|
|
</p>
|
|
<time className="shrink-0 whitespace-nowrap text-xs text-muted-foreground">
|
|
{timeLabel(event.at)}
|
|
</time>
|
|
</div>
|
|
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{event.file}</p>
|
|
{event.detail && (
|
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{event.detail}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusLine({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
<span className="text-sm font-medium">{value}</span>
|
|
</div>
|
|
<Separator />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ActivitySidebar({
|
|
side,
|
|
title,
|
|
collapsedLabel,
|
|
testId,
|
|
description,
|
|
events,
|
|
connected,
|
|
open,
|
|
onToggle,
|
|
emptyTitle,
|
|
emptyDescription,
|
|
}: {
|
|
side: 'left' | 'right';
|
|
title: string;
|
|
collapsedLabel: string;
|
|
testId: string;
|
|
description: string;
|
|
events: PodActivityEvent[];
|
|
connected: boolean;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
emptyTitle: string;
|
|
emptyDescription: string;
|
|
}) {
|
|
const ToggleIcon = side === 'left' ? PanelLeftIcon : PanelRightIcon;
|
|
const critical = events.filter((event) => event.severity === 'critical').length;
|
|
const toggleTestId = testId.replace('-sidebar', '-toggle');
|
|
|
|
return (
|
|
<Sidebar
|
|
side={side}
|
|
collapsible="icon"
|
|
className="border-border/80 bg-sidebar"
|
|
data-testid={testId}
|
|
>
|
|
<SidebarHeader className="border-b px-3 py-3">
|
|
<div className="flex min-w-0 items-start justify-between gap-2 group-data-[collapsible=icon]:hidden">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="truncate text-sm font-semibold">{title}</h2>
|
|
<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 className="mt-3 grid grid-cols-2 gap-2">
|
|
<StreamStat label="Events" value={events.length} />
|
|
<StreamStat label="Critical" value={critical} />
|
|
</div>
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon-sm" onClick={onToggle} data-testid={toggleTestId}>
|
|
<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}
|
|
data-testid={toggleTestId}
|
|
className="hidden min-h-32 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={cn(
|
|
'size-2 rounded-full',
|
|
connected ? 'bg-chart-2' : 'bg-muted-foreground/35',
|
|
)}
|
|
/>
|
|
<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>
|
|
{critical > 0 && (
|
|
<Badge variant="destructive" className="rounded-md px-1.5 py-0">
|
|
{critical}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
</SidebarHeader>
|
|
<SidebarContent className="px-3 py-3">
|
|
<div className="group-data-[collapsible=icon]:hidden">
|
|
{events.length ? (
|
|
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-4 overflow-y-auto pr-1">
|
|
{ACTIVITY_CATEGORIES.map((category) => {
|
|
const items = events.filter((event) => CATEGORY_OF[event.kind] === category.id);
|
|
if (!items.length) return null;
|
|
const CategoryIcon = category.icon;
|
|
return (
|
|
<section key={category.id} className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 px-0.5">
|
|
<CategoryIcon className="size-3.5 text-muted-foreground" />
|
|
<h3 className="text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{category.label}
|
|
</h3>
|
|
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[0.65rem]">
|
|
{items.length}
|
|
</Badge>
|
|
</div>
|
|
<p className="-mt-1 px-0.5 text-[0.65rem] leading-4 text-muted-foreground/70">
|
|
{category.hint}
|
|
</p>
|
|
<div className="flex flex-col gap-3">
|
|
{items.map((event) => (
|
|
<ActivityItem key={event.id} event={event} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<Empty className="min-h-72 border-0 p-0">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<RadioTowerIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
|
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)}
|
|
</div>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function StreamStat({ label, value }: { label: string; value: number }) {
|
|
return (
|
|
<div className="rounded-md border bg-background px-2 py-1.5">
|
|
<p className="text-[0.65rem] font-medium uppercase text-muted-foreground">{label}</p>
|
|
<p className="mt-0.5 font-mono text-sm font-medium">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityItem({ event }: { event: PodActivityEvent }) {
|
|
const Icon = activityIcon(event.kind);
|
|
const metadata = activityMetadata(event);
|
|
const title = activityTitle(event);
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'grid min-h-20 shrink-0 grid-cols-[2.25rem_minmax(0,1fr)] gap-3 overflow-hidden rounded-lg border bg-card px-3.5 py-3.5 shadow-sm',
|
|
event.severity === 'critical' && 'border-destructive/45 bg-destructive/5',
|
|
event.severity === 'warn' && 'border-chart-3/45 bg-chart-3/5',
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'flex size-9 items-center justify-center rounded-md border bg-muted text-muted-foreground',
|
|
event.severity === 'critical' && 'border-destructive/35 text-destructive',
|
|
event.severity === 'success' && 'border-chart-2/35 text-chart-2',
|
|
)}
|
|
>
|
|
<Icon className="size-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2">
|
|
<p className="line-clamp-2 min-w-0 break-words text-sm font-medium leading-5">{title}</p>
|
|
<time className="shrink-0 whitespace-nowrap text-xs text-muted-foreground">
|
|
{timeLabel(event.at)}
|
|
</time>
|
|
</div>
|
|
{event.detail && (
|
|
<p className="mt-1 line-clamp-2 break-words text-xs leading-5 text-muted-foreground">
|
|
{event.detail}
|
|
</p>
|
|
)}
|
|
{event.imageUrl && (
|
|
<div className="mt-2 overflow-hidden rounded-md border bg-muted">
|
|
<img
|
|
src={event.imageUrl}
|
|
alt={activityImageAlt(event)}
|
|
loading="lazy"
|
|
className="aspect-video w-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
|
<SourceChip source={event.source} />
|
|
<Badge variant="outline" className="rounded-md px-1.5 py-0 text-[0.68rem] leading-4">
|
|
{KIND_LABEL[event.kind]}
|
|
</Badge>
|
|
{metadata.map((item, index) => (
|
|
<ActivityBadge
|
|
key={`${item.variant}-${item.label}-${index}`}
|
|
variant={item.variant}
|
|
title={item.title}
|
|
>
|
|
{item.label}
|
|
</ActivityBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function activityMetadata(event: PodActivityEvent): {
|
|
label: string;
|
|
title?: string;
|
|
variant: 'secondary' | 'outline';
|
|
}[] {
|
|
const actors = event.actors?.length ? event.actors : event.actor ? [event.actor] : [];
|
|
const visibleActors = actors.slice(0, 2).map((actor) => ({
|
|
label: actor,
|
|
variant: 'secondary' as const,
|
|
}));
|
|
const hiddenActors = actors.length - visibleActors.length;
|
|
return [
|
|
...visibleActors,
|
|
...(hiddenActors > 0
|
|
? [
|
|
{
|
|
label: `+${hiddenActors}`,
|
|
title: actors.slice(2).join(', '),
|
|
variant: 'secondary' as const,
|
|
},
|
|
]
|
|
: []),
|
|
...(event.file ? [{ label: event.file, variant: 'outline' as const }] : []),
|
|
];
|
|
}
|
|
|
|
function ActivityBadge({
|
|
variant,
|
|
children,
|
|
title,
|
|
}: {
|
|
variant: 'secondary' | 'outline';
|
|
children: string;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<Badge
|
|
variant={variant}
|
|
title={title ?? children}
|
|
className="min-w-0 max-w-[9rem] rounded-md px-1.5 py-0 text-[0.68rem] leading-4"
|
|
>
|
|
<span className="truncate">{children}</span>
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
type ActivityCategoryId = 'signal' | 'decision';
|
|
|
|
const CATEGORY_OF: Record<PodActivityKind, ActivityCategoryId> = {
|
|
observation: 'signal',
|
|
git: 'signal',
|
|
collision: 'decision',
|
|
intervention: 'decision',
|
|
outcome: 'decision',
|
|
};
|
|
|
|
const ACTIVITY_CATEGORIES: {
|
|
id: ActivityCategoryId;
|
|
label: string;
|
|
hint: string;
|
|
icon: typeof RadioTowerIcon;
|
|
}[] = [
|
|
{
|
|
id: 'signal',
|
|
label: 'Signals',
|
|
hint: 'Screen logs and local git activity routed to this stream.',
|
|
icon: RadioTowerIcon,
|
|
},
|
|
{
|
|
id: 'decision',
|
|
label: 'Reasoning & decisions',
|
|
hint: 'What Hermes concluded and acted on — conflicts, interventions, outcomes.',
|
|
icon: WorkflowIcon,
|
|
},
|
|
];
|
|
|
|
const KIND_LABEL: Record<PodActivityKind, string> = {
|
|
observation: 'Screen log',
|
|
git: 'Git',
|
|
collision: 'Conflict',
|
|
intervention: 'Intervention',
|
|
outcome: 'Outcome',
|
|
};
|
|
|
|
const SOURCE_META: Record<
|
|
PodActivitySource,
|
|
{ label: string; icon: typeof RadioTowerIcon; className: string }
|
|
> = {
|
|
vision: {
|
|
label: 'Screen',
|
|
icon: EyeIcon,
|
|
className: 'border-chart-1/40 bg-chart-1/10 text-chart-1',
|
|
},
|
|
git: {
|
|
label: 'Git',
|
|
icon: GitBranchIcon,
|
|
className: 'border-chart-2/40 bg-chart-2/10 text-chart-2',
|
|
},
|
|
memory: {
|
|
label: 'Memory',
|
|
icon: BrainIcon,
|
|
className: 'border-chart-4/40 bg-chart-4/10 text-chart-4',
|
|
},
|
|
hermes: {
|
|
label: 'Hermes',
|
|
icon: SparklesIcon,
|
|
className: 'border-primary/40 bg-primary/10 text-primary',
|
|
},
|
|
policy: {
|
|
label: 'Policy',
|
|
icon: ShieldIcon,
|
|
className: 'border-chart-3/40 bg-chart-3/10 text-chart-3',
|
|
},
|
|
};
|
|
|
|
function SourceChip({ source }: { source: PodActivitySource }) {
|
|
const meta = SOURCE_META[source];
|
|
const Icon = meta.icon;
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
title={`Source: ${meta.label}`}
|
|
className={cn(
|
|
'gap-1 rounded-md border px-1.5 py-0 text-[0.68rem] font-medium leading-4',
|
|
meta.className,
|
|
)}
|
|
>
|
|
<Icon className="size-3" />
|
|
{meta.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function activityTitle(event: PodActivityEvent): string {
|
|
if (event.kind !== 'observation') return event.title;
|
|
return event.title.startsWith('Screen') ? event.title : `Screen log: ${event.title}`;
|
|
}
|
|
|
|
function activityImageAlt(event: PodActivityEvent): string {
|
|
const actor = event.actor ?? event.actors?.[0] ?? 'teammate';
|
|
return `${actor} screen thumbnail`;
|
|
}
|
|
|
|
function activityIcon(kind: PodActivityKind) {
|
|
switch (kind) {
|
|
case 'git':
|
|
return GitBranchIcon;
|
|
case 'collision':
|
|
return TriangleAlertIcon;
|
|
case 'intervention':
|
|
return MessageSquareIcon;
|
|
case 'outcome':
|
|
return CheckIcon;
|
|
case 'observation':
|
|
default:
|
|
return MonitorUpIcon;
|
|
}
|
|
}
|
|
|
|
function timeLabel(value: string): string {
|
|
const then = Date.parse(value);
|
|
if (!Number.isFinite(then)) return '--';
|
|
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
if (seconds < 10) return 'now';
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h`;
|
|
return `${Math.floor(hours / 24)}d`;
|
|
}
|
|
|
|
function initials(name: string): string {
|
|
return name
|
|
.split(/\s+/)
|
|
.map((w) => w[0] ?? '')
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
}
|