1import { useNavigate } from "@tanstack/react-router";
2import { DownloadIcon } from "lucide-react";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5import { useEnvironments } from "~/state/environments";
6import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
7import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
8import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows";
9import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
10import {
11 collectProviderUpdateCandidates,
12 environmentGroupsWithUpdates,
13 getProviderUpdateInitialToastView,
14 localEnvironmentUpdateNotificationKey,
15} from "./ProviderUpdateLaunchNotification.logic";
16import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification";
17import { stackedThreadToast, toastManager } from "./ui/toast";
18
19/**
20 * True when a desktop-local secondary backend (the parallel WSL backend) is
21 * present alongside the primary. Local secondaries connect over loopback with a
22 * `local:<backendInstanceId>` bearer connection id; everything else (SSH, relay,
23 * remote) is ignored. Gating on this keeps non-WSL users on the unchanged
24 * single-prompt flow.
25 */
26function useHasLocalSecondaryEnvironment(): boolean {
27 const { environments } = useEnvironments();
28 return useMemo(
29 () =>
30 environments.some((environment) => isDesktopLocalConnectionTarget(environment.entry.target)),
31 [environments],
32 );
33}
34
35/**
36 * The provider update popover. With a WSL backend present it splits the update
37 * trigger per environment; without one (the common case) it falls back to the
38 * single-prompt flow so non-WSL users see no change.
39 */
40export function ProviderUpdateLaunchNotification() {
41 const hasLocalSecondary = useHasLocalSecondaryEnvironment();
42
43 return hasLocalSecondary ? (
44 <ProviderUpdateEnvironmentsNotification />
45 ) : (
46 <ProviderUpdatePrimaryNotification />
47 );
48}
49
50const seenProviderUpdateNotificationKeys = new Set<string>();
51type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
52
53// While a local backend (e.g. WSL) is still connecting, defer the popover so it
54// reflects every environment. Cap the wait so a stuck or failed backend can't
55// suppress the primary's updates indefinitely.
56const SETTLING_GRACE_MS = 30_000;
57
58function ProviderUpdateEnvironmentsNotification() {
59 const navigate = useNavigate();
60 const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups();
61 const { dismissedNotificationKeys, dismissNotificationKey } =
62 useDismissedProviderUpdateNotificationKeys();
63
64 const activeToastRef = useRef<{
65 readonly toastId: ProviderUpdateToastId;
66 readonly key: string;
67 } | null>(null);
68 const notificationKeyRef = useRef<string | null>(null);
69 // Whether the user has triggered an update from the current toast. Until they
70 // do, the prompt is replaced when the available updates change; afterward it
71 // is kept so in-progress rows are not torn down.
72 const hasInteractedRef = useRef(false);
73
74 // Close our prompt if this flow unmounts (e.g. the WSL backend is disabled
75 // and we fall back to the single-prompt flow).
76 useEffect(() => {
77 return () => {
78 if (activeToastRef.current !== null) {
79 toastManager.close(activeToastRef.current.toastId);
80 activeToastRef.current = null;
81 }
82 };
83 }, []);
84
85 const updateGroups = useMemo(() => environmentGroupsWithUpdates(groups), [groups]);
86 const notificationKey = useMemo(() => localEnvironmentUpdateNotificationKey(groups), [groups]);
87 useEffect(() => {
88 notificationKeyRef.current = notificationKey;
89 }, [notificationKey]);
90
91 // Title summarizes the distinct providers on offer across all environments;
92 // the per-environment detail lives in the popover body.
93 const candidateUnion = useMemo(
94 () => collectProviderUpdateCandidates(updateGroups.flatMap((group) => group.candidates)),
95 [updateGroups],
96 );
97
98 // Defer while any local backend is still connecting, up to the grace period.
99 const [settleGraceElapsed, setSettleGraceElapsed] = useState(false);
100 useEffect(() => {
101 if (!isAnySettling) {
102 setSettleGraceElapsed(false);
103 return;
104 }
105 const timer = setTimeout(() => setSettleGraceElapsed(true), SETTLING_GRACE_MS);
106 return () => clearTimeout(timer);
107 }, [isAnySettling]);
108 const isGated = isAnySettling && !settleGraceElapsed;
109
110 const openProviderSettings = useCallback(() => {
111 const active = activeToastRef.current;
112 if (active !== null) {
113 toastManager.close(active.toastId);
114 activeToastRef.current = null;
115 }
116 void navigate({ to: "/settings/providers" });
117 }, [navigate]);
118
119 useEffect(() => {
120 // Whether a fresh prompt can actually be shown for the current update set.
121 const canShowPrompt =
122 notificationKey !== null &&
123 !isGated &&
124 !dismissedNotificationKeys.has(notificationKey) &&
125 !seenProviderUpdateNotificationKeys.has(notificationKey);
126
127 // Close a prompt the user hasn't acted on yet when the available updates
128 // change: when they clear entirely (key null) so the toast doesn't linger,
129 // and when a fresh set is ready to replace it. Keep it only while a backend
130 // is re-settling (updates still exist, just gated) — and once an update is
131 // in progress, so its rows survive.
132 const active = activeToastRef.current;
133 if (
134 active &&
135 active.key !== notificationKey &&
136 !hasInteractedRef.current &&
137 (notificationKey === null || !isGated)
138 ) {
139 toastManager.close(active.toastId);
140 activeToastRef.current = null;
141 }
142
143 if (!notificationKey || !canShowPrompt || activeToastRef.current !== null) {
144 return;
145 }
146
147 seenProviderUpdateNotificationKeys.add(notificationKey);
148 hasInteractedRef.current = false;
149
150 const dismissPrompt = () => {
151 // Dismiss whatever set is still on offer at close time, so the popover
152 // does not re-pop for updates the user just declined.
153 const liveKey = notificationKeyRef.current;
154 if (liveKey) {
155 dismissNotificationKey(liveKey);
156 }
157 activeToastRef.current = null;
158 };
159
160 const toastId = toastManager.add(
161 stackedThreadToast({
162 type: "warning",
163 title: getProviderUpdateInitialToastView({
164 updateProviders: candidateUnion,
165 oneClickProviders: candidateUnion,
166 }).title,
167 description: (
168 <ProviderUpdateEnvironmentRows
169 onInteract={() => {
170 hasInteractedRef.current = true;
171 }}
172 />
173 ),
174 timeout: 0,
175 actionProps: {
176 children: "Settings",
177 onClick: openProviderSettings,
178 },
179 actionVariant: "outline",
180 data: {
181 hideCopyButton: true,
182 leadingIcon: <DownloadIcon aria-hidden="true" className="size-4 text-success" />,
183 onClose: dismissPrompt,
184 },
185 }),
186 );
187 activeToastRef.current = { toastId, key: notificationKey };
188 }, [
189 notificationKey,
190 isGated,
191 candidateUnion,
192 dismissedNotificationKeys,
193 dismissNotificationKey,
194 openProviderSettings,
195 ]);
196
197 return null;
198}