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
+
+
+ {CONNECTION_TEXT[status]}
+
+
+ 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 (
+
+ );
+ }
+);
+
+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,
+ };
}