1import { useAtomValue } from "@effect/atom-react";
2import { EnvironmentId } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4
5import { environmentThreadShells } from "~/state/threads";
6import {
7 AlertDialog,
8 AlertDialogClose,
9 AlertDialogDescription,
10 AlertDialogFooter,
11 AlertDialogHeader,
12 AlertDialogPopup,
13 AlertDialogTitle,
14} from "../ui/alert-dialog";
15import { Button } from "../ui/button";
16import type { RestartingMachine, SkippedMachine } from "./updateEverything.logic";
17
18/** A thread an agent is working on now. A restart stops these; queued threads wait. */
19const isRunning = (status: string) =>
20 status === "preparing" || status === "starting" || status === "running";
21
22/** Running threads across the environments in a newline-joined key. */
23const runningThreadCountAtom = Atom.family((environmentIds: string) =>
24 Atom.make((get) =>
25 environmentIds
26 .split("\n")
27 .filter((id) => id !== "")
28 .reduce(
29 (count, id) =>
30 count +
31 get(environmentThreadShells.environmentThreadsAtom(EnvironmentId.make(id))).filter(
32 (thread) => isRunning(thread.status),
33 ).length,
34 0,
35 ),
36 ).pipe(Atom.withLabel(`update-everything-running-threads:${environmentIds}`)),
37);
38
39interface UpdateEverythingDialogProps {
40 readonly open: boolean;
41 readonly onOpenChange: (open: boolean) => void;
42 readonly summary: string;
43 readonly lines: ReadonlyArray<string>;
44 readonly restarts: ReadonlyArray<RestartingMachine>;
45 /** Behind machines the run leaves alone. */
46 readonly skipped: ReadonlyArray<SkippedMachine>;
47 readonly onConfirm: () => void;
48}
49
50/**
51 * Confirms an "update everything" run. It counts the running threads that the
52 * restarts stop, and how many can resume afterwards because "Continue threads
53 * after restarts" is on for their server. Recovery still skips some, such as a
54 * thread waiting on the user, so the text says "can resume", not "will".
55 * Machines the run skips are listed with the reason, so none drop silently.
56 */
57export function UpdateEverythingDialog({
58 open,
59 onOpenChange,
60 ...content
61}: UpdateEverythingDialogProps) {
62 return (
63 <AlertDialog open={open} onOpenChange={onOpenChange}>
64 <AlertDialogPopup>
65 {/* The popup unmounts when closed, so the thread count is read only while open. */}
66 <UpdateEverythingDialogContent {...content} />
67 </AlertDialogPopup>
68 </AlertDialog>
69 );
70}
71
72function UpdateEverythingDialogContent({
73 summary,
74 lines,
75 restarts,
76 skipped,
77 onConfirm,
78}: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) {
79 const stopped = useAtomValue(
80 runningThreadCountAtom(
81 restarts
82 .filter((machine) => !machine.continuesThreads)
83 .map((machine) => machine.environmentId)
84 .join("\n"),
85 ),
86 );
87 const continuing = useAtomValue(
88 runningThreadCountAtom(
89 restarts
90 .filter((machine) => machine.continuesThreads)
91 .map((machine) => machine.environmentId)
92 .join("\n"),
93 ),
94 );
95 const running = stopped + continuing;
96 const threads = `${running} running ${running === 1 ? "thread" : "threads"}`;
97
98 return (
99 <>
100 <AlertDialogHeader>
101 <AlertDialogTitle>{summary}?</AlertDialogTitle>
102 <AlertDialogDescription className="whitespace-pre-line">
103 {lines.join("\n")}
104 </AlertDialogDescription>
105 {running > 0 ? (
106 <p className="pt-2 text-sm">
107 {stopped === 0
108 ? `${threads} will be interrupted and can resume after the restart.`
109 : continuing === 0
110 ? `${threads} will be interrupted.`
111 : `${threads} will be interrupted. ${continuing} can resume after the restart.`}
112 </p>
113 ) : null}
114 {skipped.length > 0 ? (
115 <p className="whitespace-pre-line pt-2 text-sm">
116 {skipped.map((machine) => `Skips ${machine.label}: ${machine.reason}`).join("\n")}
117 </p>
118 ) : null}
119 </AlertDialogHeader>
120 <AlertDialogFooter>
121 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
122 <Button onClick={onConfirm}>Update</Button>
123 </AlertDialogFooter>
124 </>
125 );
126}