1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import {
4 AuthProvidersManageScope,
5 AuthOrchestrationReadScope,
6 type EnvironmentId,
7 type ServerProvider,
8} from "@t3tools/contracts";
9import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10import { AsyncResult } from "effect/reactivity";
11import { useEffect, useRef, useState } from "react";
12import { Alert, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
14
15import { AppText as Text } from "../../components/AppText";
16import { ProviderIcon } from "../../components/ProviderIcon";
17import { ScreenScrollView } from "../../components/ScreenScrollView";
18import { appAtomRegistry } from "../../state/atom-registry";
19import { serverEnvironment } from "../../state/server";
20import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
21import { useAtomCommand } from "../../state/use-atom-command";
22import { useRemoteConnections } from "../../state/use-remote-environment-registry";
23import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
24import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
25import { SettingsActionRow } from "./components/SettingsActionRow";
26import { SettingsScreen } from "./components/SettingsScreen";
27import { SettingsSection } from "./components/SettingsSection";
28import {
29 canMaintainEnvironment,
30 canUpdateEnvironmentProvider,
31 findEnvironmentUpdate,
32 supportsEnvironmentUpdate,
33} from "./environment-maintenance";
34
35export function SettingsEnvironmentDetailRouteScreen({
36 route,
37}: StaticScreenProps<{
38 readonly environmentId: EnvironmentId;
39}>) {
40 // Key local request state to the host even when navigation reuses this screen.
41 return (
42 <EnvironmentDetail
43 key={route.params.environmentId}
44 environmentId={route.params.environmentId}
45 />
46 );
47}
48
49function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {
50 const insets = useSafeAreaInsets();
51 const navigation = useNavigation();
52 const connections = useRemoteConnections();
53 const environment = connections.connectedEnvironments.find(
54 (entry) => entry.environmentId === environmentId,
55 );
56 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
57 const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId));
58 const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
59 const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
60 const updateServer = useAtomCommand(serverEnvironment.updateServer);
61 const updateProvider = useAtomCommand(serverEnvironment.updateProvider);
62 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders);
63 const [connectionExpanded, setConnectionExpanded] = useState(false);
64 const [pending, setPending] = useState<string | null>(null);
65 const pendingRef = useRef(false);
66 const [error, setError] = useState<string | null>(null);
67 const [notice, setNotice] = useState<string | null>(null);
68 const [release, setRelease] = useState<{
69 fromVersion: string;
70 targetVersion: string | null;
71 } | null>(null);
72 const checkController = useRef<AbortController | null>(null);
73 useEffect(() => () => checkController.current?.abort(), []);
74
75 const connected = environment?.isEnabled === true && environment.connectionState === "connected";
76 const allowed =
77 !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected);
78 const running = updateState.status === "running";
79 const providerBusy =
80 config?.providers.some(
81 (provider) =>
82 provider.updateState?.status === "running" || provider.updateState?.status === "queued",
83 ) ?? false;
84 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
85 const canRead = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
86 const busy = !connected || pending !== null || running || providerBusy;
87 const disabled = !allowed || busy;
88 const version = config?.environment.serverVersion;
89 const checkedRelease = release?.fromVersion === version ? release : null;
90 const capabilities = config?.environment.capabilities;
91
92 async function run(label: string, action: () => Promise<void>) {
93 if (pendingRef.current) return;
94 pendingRef.current = true;
95 setPending(label);
96 setError(null);
97 setNotice(null);
98 try {
99 await action();
100 } catch (cause) {
101 setError(
102 cause instanceof Error ? cause.message : "The action could not be completed. Try again.",
103 );
104 } finally {
105 pendingRef.current = false;
106 setPending(null);
107 }
108 }
109
110 function requestServerUpdate() {
111 const targetVersion = checkedRelease?.targetVersion;
112 if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities))
113 return;
114 Alert.alert(
115 `Update ${environment?.environmentLabel ?? "environment"}?`,
116 `Install The Knife ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`,
117 [
118 { text: "Cancel", style: "cancel" },
119 {
120 text: "Update",
121 onPress: () =>
122 void run("server", async () => {
123 if (
124 AsyncResult.isFailure(
125 appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)),
126 ) ||
127 !canMaintainEnvironment(
128 appAtomRegistry.get(environmentSession.sessionStateValueAtom(environmentId)),
129 connected,
130 )
131 )
132 return;
133 const result = await updateServer({
134 environmentId,
135 input: {
136 targetVersion,
137 ...(capabilities.serverUpdateThreadContinuation &&
138 config?.settings.continueThreadsAfterServerUpdate
139 ? { continueRunningThreads: true }
140 : {}),
141 },
142 });
143 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
144 setRelease(null);
145 setNotice(`Updated to ${result.value.targetVersion}.`);
146 }),
147 },
148 ],
149 );
150 }
151
152 function requestProviderUpdate(provider: ServerProvider) {
153 if (
154 busy ||
155 !readEnvironmentScope(environmentId, AuthProvidersManageScope) ||
156 !canUpdateEnvironmentProvider(provider)
157 )
158 return;
159 void run(provider.instanceId, async () => {
160 const result = await updateProvider({
161 environmentId,
162 input: {
163 provider: provider.driver,
164 instanceId: provider.instanceId,
165 },
166 });
167 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
168 });
169 }
170
171 return (
172 <SettingsScreen title={environment?.environmentLabel ?? "Environment"}>
173 <ScreenScrollView
174 contentInsetAdjustmentBehavior="automatic"
175 className="flex-1"
176 contentContainerClassName="gap-6 px-5 pt-4"
177 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
178 >
179 {!environment ? (
180 <Text className="text-base text-foreground-muted">
181 This environment is no longer saved on this device.
182 </Text>
183 ) : (
184 <>
185 <SettingsSection title="Connection">
186 <ConnectionEnvironmentRow
187 environment={environment}
188 expanded={connectionExpanded}
189 onToggle={() => setConnectionExpanded((value) => !value)}
190 onReconnect={connections.onReconnectEnvironment}
191 onRemove={connections.onRemoveEnvironmentPress}
192 onSetEnabled={connections.onSetEnvironmentEnabled}
193 onUpdate={connections.onUpdateEnvironment}
194 />
195 </SettingsSection>
196 <EnvironmentRoutesSection
197 environmentId={environmentId}
198 connected={connected}
199 onAddRoute={() =>
200 navigation.navigate("SettingsSheet", {
201 screen: "SettingsContent",
202 params: {
203 screen: "SettingsEnvironmentNew",
204 params: { routeFor: environmentId },
205 },
206 })
207 }
208 />
209 {!connected ? (
210 <Text className="px-2 text-sm text-foreground-muted">
211 Connect this environment to manage it.
212 </Text>
213 ) : !allowed ? (
214 <Text className="px-2 text-sm text-foreground-muted">
215 {AsyncResult.isFailure(sessionResult)
216 ? "Could not verify your permissions. Reconnect to try again."
217 : session === null
218 ? "Checking permissions…"
219 : "This connection does not have permission to manage the environment."}
220 </Text>
221 ) : null}
222 {error ? (
223 <Text selectable className="px-2 text-sm text-danger-foreground">
224 {error}
225 </Text>
226 ) : null}
227 {notice ? <Text className="px-2 text-sm text-foreground-muted">{notice}</Text> : null}
228 {config ? (
229 <>
230 <SettingsSection title="The Knife">
231 <View className="gap-1 p-4">
232 <Text className="text-base text-foreground">Version {version}</Text>
233 {running ? (
234 <Text className="text-sm text-foreground-muted">
235 {updateState.stage === "resuming"
236 ? "Restarting and reconnecting…"
237 : "Downloading update…"}
238 </Text>
239 ) : updateState.status === "failed" ? (
240 <Text selectable className="text-sm text-danger-foreground">
241 {updateState.message}
242 </Text>
243 ) : null}
244 {checkedRelease ? (
245 <Text className="text-sm text-foreground-muted">
246 {checkedRelease.targetVersion
247 ? `Version ${checkedRelease.targetVersion} is available.`
248 : "You are up to date."}
249 </Text>
250 ) : null}
251 {!supportsEnvironmentUpdate(config.environment.capabilities) ? (
252 <Text className="text-sm text-foreground-muted">
253 {capabilities?.serverSelfUpdate === "desktop-managed"
254 ? "Update the desktop app on this machine."
255 : "Update and restart The Knife on this machine."}
256 </Text>
257 ) : null}
258 </View>
259 <SettingsActionRow
260 icon="arrow.clockwise"
261 label="Check for updates"
262 disabled={disabled}
263 loading={pending === "check"}
264 onPress={() => {
265 if (disabled) return;
266 void run("check", async () => {
267 const controller = new AbortController();
268 checkController.current = controller;
269 const timeout = setTimeout(() => controller.abort(), 20_000);
270 try {
271 const targetVersion = await findEnvironmentUpdate(
272 config.environment.serverVersion,
273 controller.signal,
274 );
275 setRelease({
276 fromVersion: config.environment.serverVersion,
277 targetVersion,
278 });
279 } finally {
280 clearTimeout(timeout);
281 checkController.current = null;
282 }
283 });
284 }}
285 />
286 {checkedRelease?.targetVersion &&
287 supportsEnvironmentUpdate(config.environment.capabilities) ? (
288 <SettingsActionRow
289 icon="arrow.up.circle"
290 label={`Update to ${checkedRelease.targetVersion}`}
291 disabled={disabled}
292 loading={pending === "server" || running}
293 onPress={requestServerUpdate}
294 />
295 ) : null}
296 </SettingsSection>
297 <SettingsSection title="Providers">
298 <SettingsActionRow
299 icon="arrow.clockwise"
300 label="Refresh providers"
301 disabled={busy || !canRead}
302 loading={pending === "refresh"}
303 onPress={() => {
304 if (busy || !canRead) return;
305 void run("refresh", async () => {
306 if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
307 return;
308 const result = await refreshProviders({ environmentId, input: {} });
309 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
310 setNotice("Provider status refreshed.");
311 });
312 }}
313 />
314 {config.providers
315 .filter((provider) => provider.enabled)
316 .map((provider) => (
317 <View key={provider.instanceId}>
318 <View className="gap-1 p-4">
319 <View className="flex-row items-center gap-2">
320 <ProviderIcon provider={provider.driver} size={18} />
321 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground">
322 {provider.displayName ?? provider.driver}
323 </Text>
324 </View>
325 <Text className="text-sm text-foreground-muted">
326 {provider.installed
327 ? (provider.version ?? "Version unknown")
328 : "Not installed"}
329 {provider.versionAdvisory?.latestVersion
330 ? ` · Latest ${provider.versionAdvisory.latestVersion}`
331 : ""}
332 </Text>
333 {provider.updateState && provider.updateState.status !== "idle" ? (
334 <Text
335 selectable
336 className={
337 provider.updateState.status === "failed"
338 ? "text-sm text-danger-foreground"
339 : "text-sm text-foreground-muted"
340 }
341 >
342 {provider.updateState.message ??
343 `Update ${provider.updateState.status}`}
344 </Text>
345 ) : null}
346 {provider.compatibilityAdvisory?.message ? (
347 <Text selectable className="text-sm text-foreground-muted">
348 {provider.compatibilityAdvisory.message}
349 </Text>
350 ) : null}
351 {provider.unavailableReason || provider.message ? (
352 <Text selectable className="text-sm text-foreground-muted">
353 {provider.unavailableReason ?? provider.message}
354 </Text>
355 ) : null}
356 {provider.versionAdvisory?.status === "behind_latest" &&
357 !provider.versionAdvisory.canUpdate ? (
358 <Text className="text-sm text-foreground-muted">
359 Update this provider on the environment's machine.
360 </Text>
361 ) : null}
362 </View>
363 {canUpdateEnvironmentProvider(provider) ? (
364 <SettingsActionRow
365 icon="arrow.up.circle"
366 label={`Update ${provider.displayName ?? provider.driver}`}
367 disabled={busy || !canManageProviders}
368 loading={pending === provider.instanceId}
369 onPress={() => requestProviderUpdate(provider)}
370 />
371 ) : null}
372 </View>
373 ))}
374 </SettingsSection>
375 </>
376 ) : null}
377 </>
378 )}
379 </ScreenScrollView>
380 </SettingsScreen>
381 );
382}