1import { useAtomValue } from "@effect/atom-react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import {
7 PROVIDER_DISPLAY_NAMES,
8 type EnvironmentId,
9 type ServerProvider,
10} from "@t3tools/contracts";
11import { Link } from "@tanstack/react-router";
12import { CheckIcon } from "lucide-react";
13import { useRef, useState } from "react";
14
15import { useEnvironments } from "~/state/environments";
16import { serverEnvironment } from "~/state/server";
17import { useAtomCommand } from "~/state/use-atom-command";
18import {
19 canOneClickUpdateProviderCandidate,
20 collectProviderUpdateCandidates,
21 isProviderUpdateActive,
22} from "../ProviderUpdateLaunchNotification.logic";
23import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
24import { providerClients } from "../settings/providerDriverMeta";
25import { Button } from "../ui/button";
26import { Spinner } from "../ui/spinner";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28
29function ProviderInstallationRow({
30 environmentId,
31 environmentLabel,
32 providers,
33 onNavigate,
34}: {
35 readonly environmentId: EnvironmentId;
36 readonly environmentLabel: string | null;
37 readonly providers: ReadonlyArray<ServerProvider>;
38 readonly onNavigate: (() => void) | undefined;
39}) {
40 const allowed = useAtomValue(serverEnvironment.updateProvider.permissionAtom(environmentId));
41 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false });
42 const inFlight = useRef(false);
43 const [pending, setPending] = useState(false);
44 const [requestError, setRequestError] = useState<string | null>(null);
45 const candidate = collectProviderUpdateCandidates(providers)[0];
46 const provider =
47 candidate ??
48 providers.find((entry) => entry.versionAdvisory?.status === "behind_latest") ??
49 providers.toSorted((a, b) => b.checkedAt.localeCompare(a.checkedAt))[0];
50 if (!provider) return null;
51
52 const name =
53 providerClients.get(provider.driver)?.label ??
54 PROVIDER_DISPLAY_NAMES[provider.driver] ??
55 provider.driver;
56 const active = providers.find(isProviderUpdateActive);
57 const updating = pending || active !== undefined;
58 const canUpdate =
59 candidate !== undefined && canOneClickUpdateProviderCandidate(candidate, providers);
60 const compatibility = provider.compatibilityAdvisory;
61 const latestBlocked =
62 compatibility?.latestVersionStatus === "broken" ||
63 compatibility?.latestVersionStatus === "unsupported";
64 const latestUpdate = providers
65 .flatMap((entry) => (entry.updateState ? [entry.updateState] : []))
66 .toSorted((a, b) =>
67 (b.finishedAt ?? b.startedAt ?? "").localeCompare(a.finishedAt ?? a.startedAt ?? ""),
68 )[0];
69 const current = providers.every((entry) => entry.versionAdvisory?.status === "current");
70 const error =
71 !current && !updating
72 ? (requestError ??
73 (latestUpdate?.status === "failed"
74 ? (latestUpdate.message ?? "Provider update failed. Try again.")
75 : null))
76 : null;
77 const versions = [
78 ...new Set(providers.map((entry) => entry.version).filter((version) => version !== null)),
79 ];
80 const installedVersion = versions.length > 0 ? versions.join(" / ") : "Version unknown";
81 const latestVersion = provider.versionAdvisory?.latestVersion;
82 const versionLabel =
83 latestVersion && !versions.every((version) => version === latestVersion)
84 ? `${installedVersion} → ${latestVersion}`
85 : installedVersion;
86
87 const handleUpdate = async () => {
88 if (!candidate || !allowed || !canUpdate || inFlight.current) return;
89
90 inFlight.current = true;
91 setPending(true);
92 setRequestError(null);
93 try {
94 const result = await updateProvider({
95 environmentId,
96 input: { provider: candidate.driver, instanceId: candidate.instanceId },
97 });
98 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
99 const failure = squashAtomCommandFailure(result);
100 setRequestError(
101 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
102 );
103 }
104 } catch (failure) {
105 setRequestError(
106 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
107 );
108 } finally {
109 inFlight.current = false;
110 setPending(false);
111 }
112 };
113
114 return (
115 <div
116 className="flex items-start gap-2 px-1.5 py-0.5"
117 aria-label={environmentLabel ? `${name} on ${environmentLabel}` : name}
118 >
119 <div className="flex h-6 w-4 shrink-0 items-center justify-center" aria-hidden="true">
120 <ProviderInstanceIcon
121 driverKind={provider.driver}
122 displayName={name}
123 iconClassName="size-4"
124 />
125 </div>
126 <div className="min-w-0 flex-1">
127 <div className="flex min-h-6 items-center gap-2 text-xs">
128 <span className="truncate font-medium">{name}</span>
129 {environmentLabel ? (
130 <span className="truncate text-muted-foreground">{environmentLabel}</span>
131 ) : null}
132 <Tooltip>
133 <TooltipTrigger
134 render={
135 <span className="ml-auto truncate text-2xs text-muted-foreground tabular-nums" />
136 }
137 >
138 {versionLabel}
139 </TooltipTrigger>
140 <TooltipPopup>{versionLabel}</TooltipPopup>
141 </Tooltip>
142 </div>
143 {latestBlocked ? (
144 <p className="mt-0.5 break-words text-2xs text-warning">
145 {compatibility?.message
146 ? `Update blocked: ${compatibility.message}`
147 : "Latest version blocked"}
148 </p>
149 ) : null}
150 {error ? (
151 <p role="alert" className="mt-0.5 break-words text-xs text-destructive">
152 {error}
153 </p>
154 ) : null}
155 {candidate && canUpdate && !allowed ? (
156 <p className="mt-0.5 text-2xs text-muted-foreground">
157 This connection cannot update providers.
158 </p>
159 ) : null}
160 </div>
161 <div className="shrink-0" aria-live="polite">
162 {updating ? (
163 <Button size="xs" variant="ghost-muted" disabled aria-label={`Updating ${name}`}>
164 <Spinner size="xs" aria-hidden="true" />
165 {active?.updateState?.status === "queued" ? "Queued" : "Updating…"}
166 </Button>
167 ) : canUpdate ? (
168 <Button
169 size="xs"
170 variant="outline"
171 disabled={!allowed}
172 onClick={() => void handleUpdate()}
173 aria-label={`${error ? "Retry" : "Update"} ${name}${environmentLabel ? ` on ${environmentLabel}` : ""}`}
174 >
175 {error ? "Retry" : "Update"}
176 </Button>
177 ) : provider.versionAdvisory?.status === "behind_latest" ? (
178 <Button
179 size="xs"
180 variant="outline"
181 render={
182 <Link
183 to="/settings/providers"
184 search={{ machine: environmentId }}
185 onClick={onNavigate}
186 />
187 }
188 aria-label={`View ${name} update details`}
189 >
190 Details
191 </Button>
192 ) : current ? (
193 <span
194 className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground"
195 role="img"
196 aria-label="Up to date"
197 >
198 <CheckIcon className="size-3.5" aria-hidden="true" />
199 </span>
200 ) : null}
201 </div>
202 </div>
203 );
204}
205
206export function AppMenuProviderUpdates({ onNavigate }: { readonly onNavigate?: () => void }) {
207 const { environments } = useEnvironments();
208 const connected = environments.filter(
209 (environment) => environment.connection.phase === "connected",
210 );
211
212 return connected.flatMap((environment) => {
213 const providers = (environment.serverConfig?.providers ?? []).filter(
214 (provider) => provider.enabled && provider.installed,
215 );
216 return [...Map.groupBy(providers, (provider) => provider.driver)].map(([driver, profiles]) => (
217 <ProviderInstallationRow
218 key={`${environment.environmentId}:${driver}`}
219 environmentId={environment.environmentId}
220 environmentLabel={connected.length > 1 ? environment.label : null}
221 providers={profiles}
222 onNavigate={onNavigate}
223 />
224 ));
225 });
226}