Open History to see the last commit
1import type {
2 DeviceServiceState,
3 EnvironmentId,
4 MessageId,
5 OrchestrationV2ThreadProjection,
6 ScopedThreadRef,
7 ThreadId,
8} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { Atom } from "effect/reactivity";
11
12import { appAtomRegistry } from "~/rpc/atomRegistry";
13import { useRightPanelStore } from "~/rightPanelStore";
14import { DeviceSetup } from "../device/DeviceSetup";
15import { PullRequestThreadDialog } from "../PullRequestThreadDialog";
16import {
17 AlertDialog,
18 AlertDialogClose,
19 AlertDialogDescription,
20 AlertDialogFooter,
21 AlertDialogHeader,
22 AlertDialogPopup,
23 AlertDialogTitle,
24} from "../ui/alert-dialog";
25import { Button } from "../ui/button";
26import { Dialog } from "../ui/dialog";
27import { WizardPopup } from "../ui/wizard";
28import { findRevertCheckpoint } from "../../session-logic";
29import { registerThreadOverlay } from "./threadOverlayRetention";
30
31/**
32 * Open state for overlays the chat view hosts. It lives in atoms rather than
33 * ChatView state so opening or closing one renders only its host, not the
34 * whole chat chrome. Each entry carries the thread it was opened for; a host
35 * shows only its own thread's entry, and leaving that thread's route drops it.
36 */
37type ThreadOverlayAtom<T> = Atom.Writable<{ readonly threadKey: string; readonly value: T } | null>;
38
39function makeThreadOverlayAtom<T>(label: string): ThreadOverlayAtom<T> {
40 const atom = Atom.make<{ readonly threadKey: string; readonly value: T } | null>(null).pipe(
41 Atom.keepAlive,
42 Atom.withLabel(`chat:${label}`),
43 );
44 registerThreadOverlay((threadKey) => {
45 const entry = appAtomRegistry.get(atom);
46 if (entry !== null && entry.threadKey !== threadKey) closeThreadOverlay(atom);
47 });
48 return atom;
49}
50
51function closeThreadOverlay<T>(atom: ThreadOverlayAtom<T>): void {
52 appAtomRegistry.set(atom, null);
53}
54
55function useThreadOverlay<T>(atom: ThreadOverlayAtom<T>, threadKey: string | null): T | null {
56 const entry = useAtomValue(atom);
57 return threadKey !== null && entry?.threadKey === threadKey ? entry.value : null;
58}
59
60const pullRequestThreadDialogAtom = makeThreadOverlayAtom<{
61 readonly initialReference: string | null;
62 readonly key: number;
63}>("pull-request-thread-dialog");
64
65export function openPullRequestThreadDialog(threadKey: string, reference?: string): void {
66 appAtomRegistry.set(pullRequestThreadDialogAtom, {
67 threadKey,
68 value: { initialReference: reference ?? null, key: Date.now() },
69 });
70}
71
72export function PullRequestThreadDialogHost(props: {
73 readonly threadKey: string | null;
74 readonly environmentId: EnvironmentId;
75 readonly threadId: ThreadId;
76 readonly cwd: string | null;
77 readonly onPrepared: (input: {
78 branch: string;
79 worktreePath: string | null;
80 pullRequestUrl: string;
81 }) => Promise<void> | void;
82}) {
83 const state = useThreadOverlay(pullRequestThreadDialogAtom, props.threadKey);
84 if (state === null) return null;
85 return (
86 <PullRequestThreadDialog
87 key={state.key}
88 open
89 environmentId={props.environmentId}
90 threadId={props.threadId}
91 cwd={props.cwd}
92 initialReference={state.initialReference}
93 onOpenChange={(open) => {
94 if (!open) closeThreadOverlay(pullRequestThreadDialogAtom);
95 }}
96 onPrepared={props.onPrepared}
97 />
98 );
99}
100
101const deviceSetupDialogAtom = makeThreadOverlayAtom<true>("device-setup-dialog");
102
103export function openDeviceSetupDialog(threadKey: string): void {
104 appAtomRegistry.set(deviceSetupDialogAtom, { threadKey, value: true });
105}
106
107export function DeviceSetupDialogHost(props: {
108 readonly threadKey: string | null;
109 readonly threadRef: ScopedThreadRef | null;
110 readonly state: DeviceServiceState;
111}) {
112 const open = useThreadOverlay(deviceSetupDialogAtom, props.threadKey) !== null;
113 const { threadRef } = props;
114 return (
115 <Dialog
116 open={open}
117 onOpenChange={(nextOpen) => {
118 if (!nextOpen) closeThreadOverlay(deviceSetupDialogAtom);
119 }}
120 >
121 <WizardPopup>
122 {threadRef ? (
123 <DeviceSetup
124 environmentId={threadRef.environmentId}
125 state={props.state}
126 onComplete={() => {
127 useRightPanelStore.getState().open(threadRef, "device");
128 closeThreadOverlay(deviceSetupDialogAtom);
129 }}
130 />
131 ) : null}
132 </WizardPopup>
133 </Dialog>
134 );
135}
136
137const branchRestoreConfirmAtom = makeThreadOverlayAtom<true>("branch-restore-confirm");
138
139export function openBranchRestoreConfirm(threadKey: string): void {
140 appAtomRegistry.set(branchRestoreConfirmAtom, { threadKey, value: true });
141}
142
143export function BranchRestoreConfirmHost(props: {
144 readonly threadKey: string | null;
145 readonly threadBranch: string;
146 readonly canWriteSourceControl: boolean;
147 readonly onConfirm: () => void;
148}) {
149 const open = useThreadOverlay(branchRestoreConfirmAtom, props.threadKey) !== null;
150 return (
151 <AlertDialog
152 open={open}
153 onOpenChange={(nextOpen) => {
154 if (!nextOpen) closeThreadOverlay(branchRestoreConfirmAtom);
155 }}
156 >
157 <AlertDialogPopup>
158 <AlertDialogHeader>
159 <AlertDialogTitle>
160 Switch to <code className="font-medium">{props.threadBranch}</code>?
161 </AlertDialogTitle>
162 <AlertDialogDescription>
163 You have uncommitted changes. They'll carry over to the other branch, or block the
164 switch if they conflict.
165 </AlertDialogDescription>
166 </AlertDialogHeader>
167 <AlertDialogFooter>
168 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
169 <Button
170 variant="default"
171 disabled={!props.canWriteSourceControl}
172 onClick={() => {
173 closeThreadOverlay(branchRestoreConfirmAtom);
174 props.onConfirm();
175 }}
176 >
177 Switch branch
178 </Button>
179 </AlertDialogFooter>
180 </AlertDialogPopup>
181 </AlertDialog>
182 );
183}
184
185interface PendingRevert {
186 readonly turnCount: number;
187 readonly messageId: MessageId;
188}
189
190const revertConfirmAtom = makeThreadOverlayAtom<PendingRevert>("revert-confirm");
191
192export function openRevertConfirm(threadKey: string, pending: PendingRevert): void {
193 appAtomRegistry.set(revertConfirmAtom, { threadKey, value: pending });
194}
195
196export function RevertConfirmHost(props: {
197 readonly threadKey: string;
198 readonly checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined;
199 readonly onConfirm: (turnCount: number, messageId: MessageId, restoreFiles: boolean) => void;
200}) {
201 const pending = useThreadOverlay(revertConfirmAtom, props.threadKey);
202 const canRestoreFiles =
203 pending !== null &&
204 findRevertCheckpoint(props.checkpoints ?? [], pending.turnCount)?.status === "ready";
205 const confirm = (restoreFiles: boolean) => {
206 if (pending === null || (restoreFiles && !canRestoreFiles)) return;
207 closeThreadOverlay(revertConfirmAtom);
208 props.onConfirm(pending.turnCount, pending.messageId, restoreFiles);
209 };
210 return (
211 <AlertDialog
212 open={pending !== null}
213 onOpenChange={(open) => {
214 if (!open) closeThreadOverlay(revertConfirmAtom);
215 }}
216 >
217 <AlertDialogPopup>
218 <AlertDialogHeader>
219 <AlertDialogTitle>Edit from here?</AlertDialogTitle>
220 <AlertDialogDescription>
221 Rewind chat to before this message. Your prompt and attachments return to the composer.
222 “Revert files too” also undoes your own edits.
223 </AlertDialogDescription>
224 </AlertDialogHeader>
225 <AlertDialogFooter>
226 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
227 <Button
228 variant="destructive-outline"
229 disabled={!canRestoreFiles}
230 onClick={() => confirm(true)}
231 >
232 Revert files too
233 </Button>
234 <Button onClick={() => confirm(false)}>Revert and keep changes</Button>
235 </AlertDialogFooter>
236 </AlertDialogPopup>
237 </AlertDialog>
238 );
239}