From adef5bdbb2171b8232c57963640d5ba5ac2dfb6d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 14:34:31 -0600 Subject: [PATCH] feat(web): MS23-P2-004 panel operator controls --- .../mission-control/GlobalAgentRoster.tsx | 2 +- .../mission-control/OrchestratorPanel.tsx | 36 ++- .../mission-control/PanelControls.tsx | 259 ++++++++++++++++++ 3 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/mission-control/PanelControls.tsx diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx index b03069d..4490c30 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx @@ -36,7 +36,7 @@ interface ProviderSessionGroup { export interface GlobalAgentRosterProps { onSelectSession?: (sessionId: string) => void; - selectedSessionId?: string; + selectedSessionId?: string | undefined; } function getStatusVariant(status: MissionControlSessionStatus): BadgeVariant { diff --git a/apps/web/src/components/mission-control/OrchestratorPanel.tsx b/apps/web/src/components/mission-control/OrchestratorPanel.tsx index f703e75..af26fa1 100644 --- a/apps/web/src/components/mission-control/OrchestratorPanel.tsx +++ b/apps/web/src/components/mission-control/OrchestratorPanel.tsx @@ -1,13 +1,15 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } 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 { PanelControls } from "@/components/mission-control/PanelControls"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSessionStream, + useSessions, type MissionControlConnectionStatus, type MissionControlMessageRole, } from "@/hooks/useMissionControl"; @@ -46,12 +48,21 @@ function formatRelativeTimestamp(timestamp: string): string { export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element { const { messages, status, error } = useSessionStream(sessionId ?? ""); + const { sessions } = useSessions(); const bottomAnchorRef = useRef(null); + const [optimisticStatus, setOptimisticStatus] = useState(null); + + const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status; + const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown"; useEffect(() => { bottomAnchorRef.current?.scrollIntoView({ block: "end" }); }, [messages.length]); + useEffect(() => { + setOptimisticStatus(null); + }, [sessionId, selectedSessionStatus]); + if (!sessionId) { return ( @@ -68,16 +79,23 @@ export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React. return ( -
+
Orchestrator Panel -
-

Session: {sessionId}

diff --git a/apps/web/src/components/mission-control/PanelControls.tsx b/apps/web/src/components/mission-control/PanelControls.tsx new file mode 100644 index 0000000..0e3c761 --- /dev/null +++ b/apps/web/src/components/mission-control/PanelControls.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { apiPost } from "@/lib/api/client"; + +const SESSIONS_QUERY_KEY = ["mission-control", "sessions"] as const; + +type PanelAction = "pause" | "resume" | "graceful-kill" | "force-kill"; +type KillConfirmationState = "graceful" | "force" | null; + +interface PanelActionResult { + nextStatus: string; +} + +export interface PanelControlsProps { + sessionId: string; + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + status: "active" | "paused" | "killed" | string; + onStatusChange?: (newStatus: string) => void; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return "Failed to update agent session."; +} + +export function PanelControls({ + sessionId, + status, + onStatusChange, +}: PanelControlsProps): React.JSX.Element { + const queryClient = useQueryClient(); + const [errorMessage, setErrorMessage] = useState(null); + const [confirmingKill, setConfirmingKill] = useState(null); + + useEffect(() => { + setErrorMessage(null); + setConfirmingKill(null); + }, [sessionId]); + + const controlMutation = useMutation({ + mutationFn: async (action: PanelAction): Promise => { + switch (action) { + case "pause": + await apiPost<{ message: string }>( + `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` + ); + return { nextStatus: "paused" }; + case "resume": + await apiPost<{ message: string }>( + `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` + ); + return { nextStatus: "active" }; + case "graceful-kill": + await apiPost<{ message: string }>( + `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + { force: false } + ); + return { nextStatus: "killed" }; + case "force-kill": + await apiPost<{ message: string }>( + `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + { force: true } + ); + return { nextStatus: "killed" }; + } + }, + onSuccess: ({ nextStatus }): void => { + setErrorMessage(null); + setConfirmingKill(null); + onStatusChange?.(nextStatus); + void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY }); + }, + onError: (error: unknown): void => { + setConfirmingKill(null); + setErrorMessage(getErrorMessage(error)); + }, + }); + + const normalizedStatus = status.toLowerCase(); + const isKilled = normalizedStatus === "killed"; + const isBusy = controlMutation.isPending; + const pendingAction = isBusy ? controlMutation.variables : undefined; + + const submitAction = (action: PanelAction): void => { + setErrorMessage(null); + controlMutation.mutate(action); + }; + + const pauseDisabled = isBusy || normalizedStatus === "paused" || isKilled; + const resumeDisabled = isBusy || normalizedStatus === "active" || isKilled; + const gracefulKillDisabled = isBusy || isKilled; + const forceKillDisabled = isBusy || isKilled; + + return ( +
+
+ + + + +
+ + {confirmingKill === "graceful" ? ( +
+

+ Gracefully stop this agent after it finishes the current step? +

+
+ + +
+
+ ) : null} +
+ +
+ + {confirmingKill === "force" ? ( +
+

+ This will hard-kill the agent immediately. +

+
+ + +
+
+ ) : null} +
+
+ + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ ); +}