1import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
3import { DownloadIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef } from "react";
5import {
6 AuthProvidersManageScope,
7 type ServerProvider,
8 type ProviderInstanceId,
9} from "@t3tools/contracts";
10
11import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
12import { usePrimaryEnvironment } from "../state/environments";
13import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
14import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
15import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
16import {
17 canOneClickUpdateProviderCandidate,
18 collectProviderUpdateCandidates,
19 collectUpdatedProviderSnapshots,
20 firstFailedProviderUpdateMessage,
21 getProviderUpdateInitialToastView,
22 getProviderUpdateProgressToastView,
23 getProviderUpdateRejectedToastView,
24 providerUpdateNotificationKey,
25 shouldShowPrimaryProviderUpdateToast,
26 type ProviderUpdateToastView,
27} from "./ProviderUpdateLaunchNotification.logic";
28import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast";
29import { useAtomCommand } from "../state/use-atom-command";
30
31const seenProviderUpdateNotificationKeys = new Set<string>();
32type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
33
34type ActiveProviderUpdateToast =
35 | {
36 readonly kind: "prompt";
37 readonly key: string;
38 readonly toastId: ProviderUpdateToastId;
39 readonly canManageProviders: boolean;
40 }
41 | {
42 readonly kind: "update";
43 readonly key: string;
44 readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>;
45 readonly providerCount: number;
46 };
47
48function ProviderUpdateToastIcon({ provider }: { provider: ServerProvider }) {
49 return (
50 <span className="relative inline-flex size-4 shrink-0 items-center justify-center">
51 <ProviderInstanceIcon
52 driverKind={provider.driver}
53 displayName={provider.displayName ?? provider.driver}
54 acpRegistryIconUrl={provider.iconUrl}
55 iconClassName="size-4"
56 />
57 {/* ProviderInstanceIcon stacks at z-30, so the badge must sit above it. */}
58 <span className="absolute -right-1 -bottom-1 z-40 inline-flex size-3 items-center justify-center rounded-full bg-popover">
59 <DownloadIcon aria-hidden="true" className="size-2.5 text-success" strokeWidth={2.5} />
60 </span>
61 </span>
62 );
63}
64
65function addProviderUpdateToast(input: {
66 readonly view: ProviderUpdateToastView;
67 readonly openSettings: (toastId: ProviderUpdateToastId) => void;
68}) {
69 if (input.view.type === "loading" || input.view.type === "success") {
70 return toastManager.add({
71 type: input.view.type,
72 title: input.view.title,
73 description: input.view.description,
74 timeout: 0,
75 actionProps: hiddenToastActionProps,
76 data: {
77 hideCopyButton: true,
78 ...(input.view.dismissAfterVisibleMs !== undefined
79 ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs }
80 : {}),
81 },
82 });
83 }
84
85 let toastId!: ProviderUpdateToastId;
86 toastId = toastManager.add(
87 stackedThreadToast({
88 type: input.view.type,
89 title: input.view.title,
90 description: input.view.description,
91 timeout: 0,
92 actionProps: {
93 children: "Settings",
94 onClick: () => input.openSettings(toastId),
95 },
96 actionVariant: "outline",
97 data: {
98 hideCopyButton: true,
99 },
100 }),
101 );
102 return toastId;
103}
104
105/**
106 * The single-prompt provider update notification used when there is only one
107 * local environment (no WSL backend). Non-WSL users see exactly this flow — the
108 * per-environment split is gated behind WSL presence.
109 */
110export function ProviderUpdatePrimaryNotification() {
111 const navigate = useNavigate();
112 const providers = useAtomValue(primaryServerProvidersAtom);
113 const primaryEnvironment = usePrimaryEnvironment();
114 const canManageProviders = useEnvironmentScope(
115 primaryEnvironment?.environmentId ?? null,
116 AuthProvidersManageScope,
117 );
118 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
119 reportFailure: false,
120 });
121 const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null);
122 const { dismissedNotificationKeys, dismissNotificationKey } =
123 useDismissedProviderUpdateNotificationKeys();
124
125 // If this flow unmounts (e.g. a WSL backend appears and we switch to the
126 // per-environment popover), close any prompt it owns so it does not linger.
127 useEffect(() => {
128 return () => {
129 const activeToast = activeToastRef.current;
130 if (activeToast?.kind === "prompt") {
131 toastManager.close(activeToast.toastId);
132 }
133 activeToastRef.current = null;
134 };
135 }, []);
136
137 const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]);
138 const notificationKey = useMemo(
139 () => providerUpdateNotificationKey(updateProviders),
140 [updateProviders],
141 );
142 const oneClickProviders = useMemo(
143 () =>
144 updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)),
145 [providers, updateProviders],
146 );
147
148 const openProviderSettings = useCallback(
149 (toastId?: ProviderUpdateToastId) => {
150 const activeToast = activeToastRef.current;
151 if (toastId !== undefined) {
152 toastManager.close(toastId);
153 } else if (activeToast?.kind === "prompt") {
154 toastManager.close(activeToast.toastId);
155 }
156 if (
157 activeToast &&
158 (toastId === undefined ||
159 (activeToast.kind === "prompt" && activeToast.toastId === toastId))
160 ) {
161 activeToastRef.current = null;
162 }
163 void navigate({ to: "/settings/providers" });
164 },
165 [navigate],
166 );
167
168 useEffect(() => {
169 const activeToast = activeToastRef.current;
170 if (activeToast?.kind !== "update") {
171 return;
172 }
173
174 const activeProviders = providers.filter((provider) =>
175 activeToast.providerInstanceIds.has(provider.instanceId),
176 );
177 const view = getProviderUpdateProgressToastView({
178 providers: activeProviders,
179 providerCount: activeToast.providerCount,
180 });
181 if (!shouldShowPrimaryProviderUpdateToast(view)) {
182 return;
183 }
184
185 addProviderUpdateToast({ view, openSettings: openProviderSettings });
186 activeToastRef.current = null;
187 }, [providers, openProviderSettings]);
188
189 useEffect(() => {
190 const activeToast = activeToastRef.current;
191 if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) {
192 toastManager.close(activeToast.toastId);
193 activeToastRef.current = null;
194 }
195
196 const updateExistingPrompt =
197 activeToast?.kind === "prompt" &&
198 activeToast.key === notificationKey &&
199 activeToast.canManageProviders !== canManageProviders;
200 if (
201 !notificationKey ||
202 dismissedNotificationKeys.has(notificationKey) ||
203 (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) ||
204 (activeToastRef.current && !updateExistingPrompt)
205 ) {
206 return;
207 }
208
209 seenProviderUpdateNotificationKeys.add(notificationKey);
210
211 const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders });
212
213 let toastId!: ProviderUpdateToastId;
214 let updateStarted = false;
215 const openSettings = () => openProviderSettings(toastId);
216 const dismissPrompt = () => {
217 dismissNotificationKey(notificationKey);
218 };
219
220 const runUpdates = () => {
221 if (
222 updateStarted ||
223 oneClickProviders.length === 0 ||
224 !primaryEnvironment ||
225 !readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)
226 ) {
227 return;
228 }
229 updateStarted = true;
230
231 const providerCount = oneClickProviders.length;
232 const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId));
233 const activeUpdate: ActiveProviderUpdateToast = {
234 kind: "update",
235 key: notificationKey,
236 providerInstanceIds,
237 providerCount,
238 };
239 activeToastRef.current = activeUpdate;
240
241 toastManager.close(toastId);
242
243 void (async () => {
244 const results = [];
245 for (const provider of oneClickProviders) {
246 if (!readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)) {
247 if (activeToastRef.current === activeUpdate) {
248 addProviderUpdateToast({
249 view: getProviderUpdateRejectedToastView(
250 providerCount,
251 "This connection cannot manage provider accounts.",
252 ),
253 openSettings: openProviderSettings,
254 });
255 activeToastRef.current = null;
256 }
257 return;
258 }
259 results.push(
260 await updateProvider({
261 environmentId: primaryEnvironment.environmentId,
262 input: {
263 provider: provider.driver,
264 instanceId: provider.instanceId,
265 },
266 }),
267 );
268 }
269
270 const activeUpdateToast = activeToastRef.current;
271 if (activeUpdateToast !== activeUpdate) {
272 return;
273 }
274
275 const failedMessage = firstFailedProviderUpdateMessage(results);
276 if (failedMessage) {
277 addProviderUpdateToast({
278 view: getProviderUpdateRejectedToastView(providerCount, failedMessage),
279 openSettings: openProviderSettings,
280 });
281 activeToastRef.current = null;
282 return;
283 }
284
285 const updatedProviderSnapshots = collectUpdatedProviderSnapshots({
286 results,
287 providerInstanceIds,
288 });
289 const view = getProviderUpdateProgressToastView({
290 providers: updatedProviderSnapshots,
291 providerCount,
292 });
293 if (shouldShowPrimaryProviderUpdateToast(view)) {
294 addProviderUpdateToast({ view, openSettings: openProviderSettings });
295 activeToastRef.current = null;
296 }
297 })();
298 };
299
300 const toastOptions = stackedThreadToast({
301 type: initialView.type,
302 title: initialView.title,
303 description: canManageProviders
304 ? initialView.description
305 : "This connection cannot manage provider accounts. View provider settings for update details.",
306 timeout: 0,
307 actionProps:
308 oneClickProviders.length > 0
309 ? {
310 children: "Update",
311 disabled: !canManageProviders,
312 onClick: runUpdates,
313 }
314 : {
315 children: "Settings",
316 onClick: openSettings,
317 },
318 actionVariant: "outline",
319 data: {
320 leadingIcon:
321 updateProviders.length === 1 ? (
322 <ProviderUpdateToastIcon provider={updateProviders[0]!} />
323 ) : undefined,
324 hideCopyButton: true,
325 onClose: dismissPrompt,
326 ...(oneClickProviders.length > 0
327 ? {
328 secondaryActionProps: {
329 children: "Settings",
330 onClick: openSettings,
331 },
332 secondaryActionVariant: "outline" as const,
333 }
334 : {}),
335 },
336 });
337 if (updateExistingPrompt && activeToast?.kind === "prompt") {
338 toastId = activeToast.toastId;
339 toastManager.update(toastId, toastOptions);
340 } else {
341 toastId = toastManager.add(toastOptions);
342 }
343 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders };
344 }, [
345 canManageProviders,
346 updateProvider,
347 dismissNotificationKey,
348 dismissedNotificationKeys,
349 notificationKey,
350 oneClickProviders,
351 openProviderSettings,
352 primaryEnvironment,
353 updateProviders,
354 ]);
355
356 return null;
357}