diff --git a/apps/web/src/components/mission-control/MissionControlLayout.tsx b/apps/web/src/components/mission-control/MissionControlLayout.tsx index 046c5d6..889d6bd 100644 --- a/apps/web/src/components/mission-control/MissionControlLayout.tsx +++ b/apps/web/src/components/mission-control/MissionControlLayout.tsx @@ -1,21 +1,86 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer"; import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster"; -import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel"; +import { + MAX_PANEL_COUNT, + MIN_PANEL_COUNT, + MissionControlPanel, + type PanelConfig, +} from "@/components/mission-control/MissionControlPanel"; import { Button } from "@/components/ui/button"; -import { useSessions } from "@/hooks/useMissionControl"; -const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as const; +const INITIAL_PANELS: PanelConfig[] = [{}]; export function MissionControlLayout(): React.JSX.Element { - const { sessions } = useSessions(); + const [panels, setPanels] = useState(INITIAL_PANELS); const [selectedSessionId, setSelectedSessionId] = useState(); - // 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; + const handleSelectSession = useCallback((sessionId: string): void => { + setSelectedSessionId(sessionId); + + setPanels((currentPanels) => { + if (currentPanels.some((panel) => panel.sessionId === sessionId)) { + return currentPanels; + } + + const firstEmptyPanelIndex = currentPanels.findIndex( + (panel) => panel.sessionId === undefined + ); + if (firstEmptyPanelIndex >= 0) { + return currentPanels.map((panel, index) => + index === firstEmptyPanelIndex ? { ...panel, sessionId } : panel + ); + } + + if (currentPanels.length >= MAX_PANEL_COUNT) { + return currentPanels; + } + + return [...currentPanels, { sessionId }]; + }); + }, []); + + const handleAddPanel = useCallback((): void => { + setPanels((currentPanels) => { + if (currentPanels.length >= MAX_PANEL_COUNT) { + return currentPanels; + } + + return [...currentPanels, {}]; + }); + }, []); + + const handleRemovePanel = useCallback((panelIndex: number): void => { + setPanels((currentPanels) => { + if (panelIndex < 0 || panelIndex >= currentPanels.length) { + return currentPanels; + } + + if (currentPanels.length <= MIN_PANEL_COUNT) { + return currentPanels; + } + + const nextPanels = currentPanels.filter((_, index) => index !== panelIndex); + return nextPanels.length === 0 ? INITIAL_PANELS : nextPanels; + }); + }, []); + + const handleExpandPanel = useCallback((panelIndex: number): void => { + setPanels((currentPanels) => { + if (panelIndex < 0 || panelIndex >= currentPanels.length) { + return currentPanels; + } + + const shouldExpand = !currentPanels[panelIndex]?.expanded; + + return currentPanels.map((panel, index) => ({ + ...panel, + expanded: shouldExpand && index === panelIndex, + })); + }); + }, []); return (
@@ -32,12 +97,17 @@ 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 f02c217..3fc17fa 100644 --- a/apps/web/src/components/mission-control/MissionControlPanel.tsx +++ b/apps/web/src/components/mission-control/MissionControlPanel.tsx @@ -1,27 +1,107 @@ "use client"; +import { useEffect } from "react"; import { OrchestratorPanel } from "@/components/mission-control/OrchestratorPanel"; +import { Button } from "@/components/ui/button"; + +export interface PanelConfig { + sessionId?: string; + expanded?: boolean; +} interface MissionControlPanelProps { - panels: readonly string[]; - panelSessionIds?: readonly (string | undefined)[]; + panels: PanelConfig[]; + onAddPanel: () => void; + onRemovePanel: (index: number) => void; + onExpandPanel: (index: number) => void; } +export const MIN_PANEL_COUNT = 1; +export const MAX_PANEL_COUNT = 6; + export function MissionControlPanel({ panels, - panelSessionIds, + onAddPanel, + onRemovePanel, + onExpandPanel, }: MissionControlPanelProps): React.JSX.Element { + const expandedPanelIndex = panels.findIndex((panel) => panel.expanded); + const expandedPanel = expandedPanelIndex >= 0 ? panels[expandedPanelIndex] : undefined; + const canAddPanel = panels.length < MAX_PANEL_COUNT; + const canRemovePanel = panels.length > MIN_PANEL_COUNT; + + useEffect(() => { + if (expandedPanelIndex < 0) { + return; + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + onExpandPanel(expandedPanelIndex); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return (): void => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [expandedPanelIndex, onExpandPanel]); + return ( -
- {panels.map((panelId, index) => { - const sessionId = panelSessionIds?.[index]; - - if (sessionId === undefined) { - return ; - } - - return ; - })} +
+
+

Panels

+ +
+
+ {expandedPanelIndex >= 0 && expandedPanel ? ( +
+ { + onRemovePanel(expandedPanelIndex); + }} + closeDisabled={!canRemovePanel} + onExpand={() => { + onExpandPanel(expandedPanelIndex); + }} + expanded + /> +
+ ) : ( +
+ {panels.map((panel, index) => ( + { + onRemovePanel(index); + }} + closeDisabled={!canRemovePanel} + onExpand={() => { + onExpandPanel(index); + }} + expanded={panel.expanded ?? false} + /> + ))} +
+ )} +
); } diff --git a/apps/web/src/components/mission-control/OrchestratorPanel.tsx b/apps/web/src/components/mission-control/OrchestratorPanel.tsx index f53495d..a22f7e8 100644 --- a/apps/web/src/components/mission-control/OrchestratorPanel.tsx +++ b/apps/web/src/components/mission-control/OrchestratorPanel.tsx @@ -5,6 +5,7 @@ import { formatDistanceToNow } from "date-fns"; import { BargeInInput } from "@/components/mission-control/BargeInInput"; 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 { PanelControls } from "@/components/mission-control/PanelControls"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -36,6 +37,64 @@ const CONNECTION_TEXT: Record = { export interface OrchestratorPanelProps { sessionId?: string; + onClose?: () => void; + closeDisabled?: boolean; + onExpand?: () => void; + expanded?: boolean; +} + +interface PanelHeaderActionsProps { + onClose?: () => void; + closeDisabled?: boolean; + onExpand?: () => void; + expanded?: boolean; +} + +function PanelHeaderActions({ + onClose, + closeDisabled = false, + onExpand, + expanded = false, +}: PanelHeaderActionsProps): React.JSX.Element | null { + if (!onClose && !onExpand) { + return null; + } + + return ( +
+ {onExpand ? ( + + ) : null} + {onClose ? ( + + ) : null} +
+ ); } function formatRelativeTimestamp(timestamp: string): string { @@ -47,7 +106,13 @@ function formatRelativeTimestamp(timestamp: string): string { return formatDistanceToNow(parsedDate, { addSuffix: true }); } -export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element { +export function OrchestratorPanel({ + sessionId, + onClose, + closeDisabled, + onExpand, + expanded, +}: OrchestratorPanelProps): React.JSX.Element { const { messages, status, error } = useSessionStream(sessionId ?? ""); const { sessions } = useSessions(); const bottomAnchorRef = useRef(null); @@ -55,6 +120,12 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React. const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status; const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown"; + const panelHeaderActionProps = { + ...(onClose !== undefined ? { onClose } : {}), + ...(closeDisabled !== undefined ? { closeDisabled } : {}), + ...(onExpand !== undefined ? { onExpand } : {}), + ...(expanded !== undefined ? { expanded } : {}), + }; useEffect(() => { bottomAnchorRef.current?.scrollIntoView({ block: "end" }); @@ -68,7 +139,10 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React. return ( - Orchestrator Panel +
+ Orchestrator Panel + +
Select an agent to view its stream @@ -80,24 +154,25 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React. return ( -
+
Orchestrator Panel -
-
-
- +
+
+
+
+

Session: {sessionId}