From 7c086db7e4f9d90dfd101aa18c4d3291f30115ed Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 14:16:22 -0600 Subject: [PATCH] feat(web): MS23-P2-005 GlobalAgentRoster sidebar tree --- .../mission-control/GlobalAgentRoster.tsx | 254 +++++++++++++++++- .../mission-control/MissionControlLayout.tsx | 11 +- apps/web/src/components/ui/collapsible.tsx | 13 + apps/web/src/components/ui/skeleton.tsx | 15 ++ 4 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/ui/collapsible.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx index 778d06d..b03069d 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx @@ -1,15 +1,259 @@ "use client"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AgentSession } from "@mosaic/shared"; +import { ChevronRight, Loader2, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import type { BadgeVariant } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible } from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet, apiPost } from "@/lib/api/client"; + +const SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const; +const SESSIONS_POLL_INTERVAL_MS = 5_000; + +type MissionControlSessionStatus = AgentSession["status"] | "killed"; + +interface MissionControlSession extends Omit { + status: MissionControlSessionStatus; + createdAt: string | Date; + updatedAt: string | Date; +} + +interface SessionsPayload { + sessions: MissionControlSession[]; +} + +interface ProviderSessionGroup { + providerId: string; + providerType: string; + sessions: MissionControlSession[]; +} + +export interface GlobalAgentRosterProps { + onSelectSession?: (sessionId: string) => void; + selectedSessionId?: string; +} + +function getStatusVariant(status: MissionControlSessionStatus): BadgeVariant { + switch (status) { + case "active": + return "status-success"; + case "paused": + return "status-warning"; + case "killed": + return "status-error"; + default: + return "status-neutral"; + } +} + +function truncateSessionId(sessionId: string): string { + return sessionId.slice(0, 8); +} + +function resolveProviderName(providerId: string, providerType: string): string { + return providerId === providerType ? providerId : `${providerId} (${providerType})`; +} + +function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGroup[] { + const grouped = new Map(); + + for (const session of sessions) { + const existing = grouped.get(session.providerId); + if (existing) { + existing.sessions.push(session); + continue; + } + + grouped.set(session.providerId, { + providerId: session.providerId, + providerType: session.providerType, + sessions: [session], + }); + } + + return Array.from(grouped.values()).sort((a, b) => a.providerId.localeCompare(b.providerId)); +} + +async function fetchSessions(): Promise { + const payload = await apiGet( + "/api/mission-control/sessions" + ); + return Array.isArray(payload) ? payload : payload.sessions; +} + +export function GlobalAgentRoster({ + onSelectSession, + selectedSessionId, +}: GlobalAgentRosterProps): React.JSX.Element { + const queryClient = useQueryClient(); + const [openProviders, setOpenProviders] = useState>({}); + + const sessionsQuery = useQuery({ + queryKey: SESSIONS_QUERY_KEY, + queryFn: fetchSessions, + refetchInterval: SESSIONS_POLL_INTERVAL_MS, + }); + + const killMutation = useMutation({ + mutationFn: async (sessionId: string): Promise => { + await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, { + force: false, + }); + return sessionId; + }, + onSuccess: (): void => { + void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY }); + }, + }); + + const groupedSessions = useMemo( + () => groupByProvider(sessionsQuery.data ?? []), + [sessionsQuery.data] + ); + + const pendingKillSessionId = killMutation.isPending ? killMutation.variables : undefined; + + const toggleProvider = (providerId: string): void => { + setOpenProviders((prev) => ({ + ...prev, + [providerId]: !(prev[providerId] ?? true), + })); + }; + + const isProviderOpen = (providerId: string): boolean => openProviders[providerId] ?? true; -export function GlobalAgentRoster(): React.JSX.Element { return ( - - Agent Roster + + + Agent Roster + {sessionsQuery.isFetching && !sessionsQuery.isLoading ? ( + - - No active agents + + {sessionsQuery.isLoading ? ( + +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+ ) : sessionsQuery.error ? ( +
+ Failed to load agents: {sessionsQuery.error.message} +
+ ) : groupedSessions.length === 0 ? ( +
+ No active agents +
+ ) : ( + +
+ {groupedSessions.map((group) => { + const providerOpen = isProviderOpen(group.providerId); + + return ( + + + {providerOpen ? ( +
+ {group.sessions.map((session) => { + const isSelected = selectedSessionId === session.id; + const isKilling = pendingKillSessionId === session.id; + + return ( +
{ + onSelectSession?.(session.id); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectSession?.(session.id); + } + }} + className="flex items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 transition-colors hover:bg-muted/40" + style={ + isSelected + ? { + borderColor: "rgba(47, 128, 255, 0.35)", + backgroundColor: "rgba(47, 128, 255, 0.08)", + } + : undefined + } + > +
+ + {truncateSessionId(session.id)} + + + {session.status} + +
+ +
+ ); + })} +
+ ) : null} +
+ ); + })} +
+
+ )}
); diff --git a/apps/web/src/components/mission-control/MissionControlLayout.tsx b/apps/web/src/components/mission-control/MissionControlLayout.tsx index 62996cb..c779899 100644 --- a/apps/web/src/components/mission-control/MissionControlLayout.tsx +++ b/apps/web/src/components/mission-control/MissionControlLayout.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster"; import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel"; import { useSessions } from "@/hooks/useMissionControl"; @@ -8,14 +9,20 @@ const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as cons export function MissionControlLayout(): React.JSX.Element { const { sessions } = useSessions(); + const [selectedSessionId, setSelectedSessionId] = useState(); - const panelSessionIds = [sessions[0]?.id, undefined, undefined, undefined] as const; + // First panel: selected session (from roster click) or first available session + const firstPanelSessionId = selectedSessionId ?? sessions[0]?.id; + const panelSessionIds = [firstPanelSessionId, undefined, undefined, undefined] as const; return (
diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..a852717 --- /dev/null +++ b/apps/web/src/components/ui/collapsible.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export interface CollapsibleProps extends React.HTMLAttributes { + open?: boolean; +} + +export function Collapsible({ + open = true, + className = "", + ...props +}: CollapsibleProps): React.JSX.Element { + return
; +} diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..310ab8d --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +export type SkeletonProps = React.HTMLAttributes; + +export const Skeleton = React.forwardRef( + ({ className = "", ...props }, ref) => ( +
+ ) +); + +Skeleton.displayName = "Skeleton";