From cd28428cf2dcb097b146e8479cea0aaa252e595a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 14:31:38 -0600 Subject: [PATCH] feat(web): MS23-P2-006 KillAllDialog confirmation modal --- .../mission-control/GlobalAgentRoster.tsx | 42 +++- .../mission-control/KillAllDialog.tsx | 224 ++++++++++++++++++ 2 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/mission-control/KillAllDialog.tsx diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx index b03069d..41e950b 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx @@ -4,6 +4,7 @@ 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 { KillAllDialog } from "@/components/mission-control/KillAllDialog"; import { Badge } from "@/components/ui/badge"; import type { BadgeVariant } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,7 +37,7 @@ interface ProviderSessionGroup { export interface GlobalAgentRosterProps { onSelectSession?: (sessionId: string) => void; - selectedSessionId?: string; + selectedSessionId?: string | undefined; } function getStatusVariant(status: MissionControlSessionStatus): BadgeVariant { @@ -87,6 +88,21 @@ async function fetchSessions(): Promise { return Array.isArray(payload) ? payload : payload.sessions; } +function toKillAllSessions(sessions: MissionControlSession[]): AgentSession[] { + return sessions + .filter( + (session): session is MissionControlSession & { status: AgentSession["status"] } => + session.status !== "killed" + ) + .map((session) => ({ + ...session, + createdAt: + session.createdAt instanceof Date ? session.createdAt : new Date(session.createdAt), + updatedAt: + session.updatedAt instanceof Date ? session.updatedAt : new Date(session.updatedAt), + })); +} + export function GlobalAgentRoster({ onSelectSession, selectedSessionId, @@ -117,6 +133,13 @@ export function GlobalAgentRoster({ [sessionsQuery.data] ); + const killAllSessions = useMemo( + () => toKillAllSessions(sessionsQuery.data ?? []), + [sessionsQuery.data] + ); + + const totalSessionCount = sessionsQuery.data?.length ?? 0; + const pendingKillSessionId = killMutation.isPending ? killMutation.variables : undefined; const toggleProvider = (providerId: string): void => { @@ -128,14 +151,23 @@ export function GlobalAgentRoster({ const isProviderOpen = (providerId: string): boolean => openProviders[providerId] ?? true; + const handleKillAllComplete = (): void => { + void queryClient.invalidateQueries({ queryKey: SESSIONS_QUERY_KEY }); + }; + return ( - + Agent Roster - {sessionsQuery.isFetching && !sessionsQuery.isLoading ? ( - diff --git a/apps/web/src/components/mission-control/KillAllDialog.tsx b/apps/web/src/components/mission-control/KillAllDialog.tsx new file mode 100644 index 0000000..b093d89 --- /dev/null +++ b/apps/web/src/components/mission-control/KillAllDialog.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { AgentSession } from "@mosaic/shared"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { apiPost } from "@/lib/api/client"; + +const CONFIRM_TEXT = "KILL ALL"; +const AUTO_CLOSE_DELAY_MS = 2_000; + +type KillScope = "internal" | "all"; + +export interface KillAllDialogProps { + sessions: AgentSession[]; + onComplete?: () => void; +} + +export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): React.JSX.Element { + const [open, setOpen] = useState(false); + const [scope, setScope] = useState("internal"); + const [confirmationInput, setConfirmationInput] = useState(""); + const [isKilling, setIsKilling] = useState(false); + const [completedCount, setCompletedCount] = useState(0); + const [targetCount, setTargetCount] = useState(0); + const [successCount, setSuccessCount] = useState(null); + const closeTimeoutRef = useRef | null>(null); + + const internalSessions = useMemo( + () => sessions.filter((session) => session.providerType.toLowerCase() === "internal"), + [sessions] + ); + + const scopedSessions = useMemo( + () => (scope === "all" ? sessions : internalSessions), + [scope, sessions, internalSessions] + ); + + const hasConfirmation = confirmationInput === CONFIRM_TEXT; + const isConfirmDisabled = + isKilling || successCount !== null || !hasConfirmation || scopedSessions.length === 0; + + useEffect((): (() => void) => { + return (): void => { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + + const resetState = (): void => { + setScope("internal"); + setConfirmationInput(""); + setIsKilling(false); + setCompletedCount(0); + setTargetCount(0); + setSuccessCount(null); + }; + + const handleOpenChange = (nextOpen: boolean): void => { + if (!nextOpen && isKilling) { + return; + } + + if (!nextOpen) { + if (closeTimeoutRef.current !== null) { + clearTimeout(closeTimeoutRef.current); + } + resetState(); + } + + setOpen(nextOpen); + }; + + const handleKillAll = async (): Promise => { + if (isConfirmDisabled) { + return; + } + + const targetSessions = [...scopedSessions]; + setIsKilling(true); + setCompletedCount(0); + setTargetCount(targetSessions.length); + setSuccessCount(null); + + const killRequests = targetSessions.map(async (session) => { + try { + await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, { + force: true, + }); + return true; + } catch { + return false; + } finally { + setCompletedCount((currentCount) => currentCount + 1); + } + }); + + const results = await Promise.all(killRequests); + const successfulKills = results.filter(Boolean).length; + + setIsKilling(false); + setSuccessCount(successfulKills); + onComplete?.(); + + closeTimeoutRef.current = setTimeout(() => { + setOpen(false); + resetState(); + }, AUTO_CLOSE_DELAY_MS); + }; + + return ( + + + + + + + Kill All Agents + + This force-kills every selected agent session. This action cannot be undone. + + + +
+
+ Scope + + +
+ +
+ + ) => { + setConfirmationInput(event.target.value); + }} + placeholder={CONFIRM_TEXT} + autoComplete="off" + disabled={isKilling} + /> +
+ + {scopedSessions.length === 0 ? ( +

No sessions in the selected scope.

+ ) : null} + + {isKilling ? ( +
+
+ ) : successCount !== null ? ( +

+ Killed {successCount} of {targetCount} agents. Closing... +

+ ) : null} +
+ + + + + +
+
+ ); +}