diff --git a/apps/web/src/components/mission-control/MissionControlLayout.tsx b/apps/web/src/components/mission-control/MissionControlLayout.tsx index 135409b..62996cb 100644 --- a/apps/web/src/components/mission-control/MissionControlLayout.tsx +++ b/apps/web/src/components/mission-control/MissionControlLayout.tsx @@ -2,10 +2,15 @@ import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster"; import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel"; +import { useSessions } from "@/hooks/useMissionControl"; const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as const; export function MissionControlLayout(): React.JSX.Element { + const { sessions } = useSessions(); + + const panelSessionIds = [sessions[0]?.id, undefined, undefined, undefined] as const; + return (
@@ -13,7 +18,7 @@ export function MissionControlLayout(): React.JSX.Element {
- +
diff --git a/apps/web/src/components/mission-control/MissionControlPanel.tsx b/apps/web/src/components/mission-control/MissionControlPanel.tsx index f17d37a..f02c217 100644 --- a/apps/web/src/components/mission-control/MissionControlPanel.tsx +++ b/apps/web/src/components/mission-control/MissionControlPanel.tsx @@ -4,14 +4,24 @@ import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPane interface MissionControlPanelProps { panels: readonly string[]; + panelSessionIds?: readonly (string | undefined)[]; } -export function MissionControlPanel({ panels }: MissionControlPanelProps): React.JSX.Element { +export function MissionControlPanel({ + panels, + panelSessionIds, +}: MissionControlPanelProps): React.JSX.Element { return (
- {panels.map((panelId) => ( - - ))} + {panels.map((panelId, index) => { + const sessionId = panelSessionIds?.[index]; + + if (sessionId === undefined) { + return ; + } + + return ; + })}
); } diff --git a/apps/web/src/components/mission-control/OrchestratorPanel.tsx b/apps/web/src/components/mission-control/OrchestratorPanel.tsx index 6bf841a..f703e75 100644 --- a/apps/web/src/components/mission-control/OrchestratorPanel.tsx +++ b/apps/web/src/components/mission-control/OrchestratorPanel.tsx @@ -1,15 +1,117 @@ "use client"; +import { useEffect, useRef } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Badge } from "@/components/ui/badge"; +import type { BadgeVariant } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + useSessionStream, + type MissionControlConnectionStatus, + type MissionControlMessageRole, +} from "@/hooks/useMissionControl"; + +const ROLE_BADGE_VARIANT: Record = { + user: "badge-blue", + assistant: "status-success", + tool: "badge-amber", + system: "badge-muted", +}; + +const CONNECTION_DOT_CLASS: Record = { + connected: "bg-emerald-500", + connecting: "bg-amber-500", + error: "bg-red-500", +}; + +const CONNECTION_TEXT: Record = { + connected: "Connected", + connecting: "Connecting", + error: "Error", +}; + +export interface OrchestratorPanelProps { + sessionId?: string; +} + +function formatRelativeTimestamp(timestamp: string): string { + const parsedDate = new Date(timestamp); + if (Number.isNaN(parsedDate.getTime())) { + return "just now"; + } + + return formatDistanceToNow(parsedDate, { addSuffix: true }); +} + +export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element { + const { messages, status, error } = useSessionStream(sessionId ?? ""); + const bottomAnchorRef = useRef(null); + + useEffect(() => { + bottomAnchorRef.current?.scrollIntoView({ block: "end" }); + }, [messages.length]); + + if (!sessionId) { + return ( + + + Orchestrator Panel + + + Select an agent to view its stream + + + ); + } -export function OrchestratorPanel(): React.JSX.Element { return ( - - Orchestrator Panel + +
+ Orchestrator Panel +
+
+
+

Session: {sessionId}

- - Select an agent + + +
+ {messages.length === 0 ? ( +

+ {error ?? "Waiting for messages..."} +

+ ) : ( + messages.map((message) => ( +
+
+ + {message.role} + + +
+

+ {message.content} +

+
+ )) + )} +
+
+ ); diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..8b75690 --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +export type ScrollAreaProps = React.HTMLAttributes; + +export const ScrollArea = React.forwardRef( + ({ className = "", children, ...props }, ref) => { + return ( +
+
{children}
+
+ ); + } +); + +ScrollArea.displayName = "ScrollArea"; diff --git a/apps/web/src/hooks/useMissionControl.ts b/apps/web/src/hooks/useMissionControl.ts index 3a5a429..0206fe7 100644 --- a/apps/web/src/hooks/useMissionControl.ts +++ b/apps/web/src/hooks/useMissionControl.ts @@ -1,10 +1,189 @@ -interface UseMissionControlResult { - sessions: []; - loading: boolean; - error: null; +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { AgentMessageRole, AgentSessionStatus } from "@mosaic/shared"; +import { apiGet } from "@/lib/api/client"; + +const MISSION_CONTROL_SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const; +const SESSIONS_REFRESH_INTERVAL_MS = 15_000; + +export type MissionControlMessageRole = AgentMessageRole; + +export interface MissionControlSession { + id: string; + providerId: string; + providerType: string; + label?: string; + status: AgentSessionStatus; + parentSessionId?: string; + createdAt: string; + updatedAt: string; + metadata?: Record; } -// Stub — will be wired in P2-002 -export function useMissionControl(): UseMissionControlResult { - return { sessions: [], loading: false, error: null }; +interface MissionControlSessionsResponse { + sessions: MissionControlSession[]; +} + +export interface MissionControlStreamMessage { + id: string; + sessionId: string; + role: MissionControlMessageRole; + content: string; + timestamp: string; + metadata?: Record; +} + +export type MissionControlConnectionStatus = "connecting" | "connected" | "error"; + +export interface UseSessionsResult { + sessions: MissionControlSession[]; + loading: boolean; + error: Error | null; +} + +export interface UseSessionStreamResult { + messages: MissionControlStreamMessage[]; + status: MissionControlConnectionStatus; + error: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isMessageRole(value: unknown): value is MissionControlMessageRole { + return value === "assistant" || value === "system" || value === "tool" || value === "user"; +} + +function isMissionControlStreamMessage(value: unknown): value is MissionControlStreamMessage { + if (!isRecord(value)) { + return false; + } + + const { id, sessionId, role, content, timestamp, metadata } = value; + + if ( + typeof id !== "string" || + typeof sessionId !== "string" || + !isMessageRole(role) || + typeof content !== "string" || + typeof timestamp !== "string" + ) { + return false; + } + + if (metadata !== undefined && !isRecord(metadata)) { + return false; + } + + return true; +} + +/** + * Fetches Mission Control sessions. + */ +export function useSessions(): UseSessionsResult { + const query = useQuery({ + queryKey: MISSION_CONTROL_SESSIONS_QUERY_KEY, + queryFn: async (): Promise => { + return apiGet("/api/mission-control/sessions"); + }, + refetchInterval: SESSIONS_REFRESH_INTERVAL_MS, + }); + + return { + sessions: query.data?.sessions ?? [], + loading: query.isLoading, + error: query.error ?? null, + }; +} + +/** + * Backward-compatible alias for early Mission Control integration. + */ +export function useMissionControl(): UseSessionsResult { + return useSessions(); +} + +/** + * Streams Mission Control session messages over SSE. + */ +export function useSessionStream(sessionId: string): UseSessionStreamResult { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState("connecting"); + const [error, setError] = useState(null); + + const eventSourceRef = useRef(null); + + useEffect(() => { + if (eventSourceRef.current !== null) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + setMessages([]); + setError(null); + + if (!sessionId) { + setStatus("connecting"); + return; + } + + if (typeof EventSource === "undefined") { + setStatus("error"); + setError("Mission Control stream is not supported by this browser."); + return; + } + + setStatus("connecting"); + + const source = new EventSource( + `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/stream` + ); + eventSourceRef.current = source; + + source.onopen = (): void => { + setStatus("connected"); + setError(null); + }; + + source.onmessage = (event: MessageEvent): void => { + try { + const parsed = JSON.parse(event.data) as unknown; + if (!isMissionControlStreamMessage(parsed)) { + return; + } + + setMessages((previousMessages) => [...previousMessages, parsed]); + } catch { + // Ignore malformed events from the stream. + } + }; + + source.onerror = (): void => { + if (source.readyState === EventSource.CONNECTING) { + setStatus("connecting"); + setError(null); + return; + } + + setStatus("error"); + setError("Mission Control stream disconnected."); + }; + + return (): void => { + source.close(); + if (eventSourceRef.current === source) { + eventSourceRef.current = null; + } + }; + }, [sessionId]); + + return { + messages, + status, + error, + }; }