authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-23 15:13:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-23 15:13:40-07:00
log68fb7f4b850991ba0ca5bd0d7c4aa13d4e5d6bb7
treedc256aa25fd89f0d29253d9f33b2c36b74f23bfc
parent11e91f126405883c0194da5b1e3ad39d81c48966
signature Signed by PGP key B5690EEEBB952194

feat(mobile): manage environment and provider updates (#13302)


13 files changed, 723 insertions(+), 80 deletions(-)

.github/workflows/mobile-eas-preview.yml+13-1
......@@ -78,7 +78,19 @@ jobs:
7878 working-directory: apps/mobile
7979 env:
8080 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
81 run: eas env:pull preview --non-interactive
81 run: |
82 eas env:pull preview --non-interactive
83 # EAS Update disables dotenv loading. Keep the downloaded native config
84 # in its process environment so Android build and OTA fingerprints match.
85 node --input-type=module <<'NODE'
86 import { appendFileSync, readFileSync } from "node:fs";
87 import { parseEnv } from "node:util";
88 const env = parseEnv(readFileSync(".env.local", "utf8"));
89 const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE;
90 if (googleServicesFile) {
91 appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`);
92 }
93 NODE
8294
8395 - name: Deploy with fingerprint check
8496 if: steps.expo-token.outputs.present == 'true'
apps/mobile/src/Stack.tsx+6
......@@ -71,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea
7171import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
7272import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
7373import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
74import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen";
7475import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
7576import {
7677 SettingsEnvironmentAgentBehaviorRouteScreen,
......@@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({
193194 title: "Environments",
194195 },
195196 }),
197 SettingsEnvironmentDetail: createNativeStackScreen({
198 screen: SettingsEnvironmentDetailRouteScreen,
199 linking: "environments/:environmentId",
200 options: { title: "Environment" },
201 }),
196202 SettingsEnvironmentNewThreads: createNativeStackScreen({
197203 screen: SettingsEnvironmentNewThreadsRouteScreen,
198204 linking: "new-threads",
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+29-20
......@@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3535
3636interface CloudEnvironmentRowsProps {
3737 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
38 readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void;
3839 readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
3940 /** Long-press on a saved row. The callback owns the confirm. */
4041 readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void;
......@@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent(
124125 ) : null}
125126
126127 {hasCloudRows ? (
127 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
128 {props.connectedCloudEnvironments.map((environment, index) => (
128 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card">
129 {props.connectedCloudEnvironments.map((environment) => (
129130 <ConnectedCloudEnvironmentRow
130131 key={environment.environmentId}
131132 environment={environment}
132133 descriptor={discoveredDescriptors.get(environment.environmentId)}
133 borderTop={index !== 0}
134134 onSetEnabled={(enabled) =>
135135 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
136136 }
137137 onRemove={() => props.onRemoveEnvironment(environment.environmentId)}
138 onOpen={
139 props.onOpenEnvironment
140 ? () => props.onOpenEnvironment?.(environment.environmentId)
141 : undefined
142 }
138143 errorExpanded={expandedErrorId === environment.environmentId}
139144 onToggleError={() => handleToggleCloudError(environment.environmentId)}
140145 />
141146 ))}
142 {availableCloudEnvironments.map((environment, index) => (
147 {availableCloudEnvironments.map((environment) => (
143148 <CloudEnvironmentRow
144149 key={environment.environment.environmentId}
145150 environment={environment}
146 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}
151 showChevron={props.onOpenEnvironment !== undefined}
147152 onConnect={() => handleConnectCloudEnvironment(environment)}
148153 errorExpanded={expandedErrorId === environment.environment.environmentId}
149154 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
......@@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent(
151156 ))}
152157 </View>
153158 ) : controller.relayDiscovery.isRefreshing ? (
154 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
159 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-grouped-card p-6">
155160 <ActivityIndicator colorClassName={"accent-icon"} />
156161 <Text className="text-center text-sm leading-normal text-foreground-muted">
157162 Loading linked cloud environments.
158163 </Text>
159164 </View>
160165 ) : controller.relayDiscovery.error ? null : (
161 <View collapsable={false} className="rounded-[24px] bg-card p-5">
166 <View collapsable={false} className="rounded-[24px] bg-grouped-card p-5">
162167 <Text className="text-sm leading-normal text-foreground-muted">
163168 No additional linked cloud environments.
164169 </Text>
......@@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent(
170175 {discoveryAvailable &&
171176 controller.relayDiscovery.error &&
172177 !controller.relayDiscovery.isRefreshing ? (
173 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
178 <View collapsable={false} className="gap-3 rounded-[24px] bg-grouped-card p-5">
174179 <Text className="text-base font-t3-bold text-foreground">
175180 Could not load T3 Connect environments
176181 </Text>
......@@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: {
202207 readonly environment: ConnectedEnvironmentSummary;
203208 /** Discovery's view of the server, for the glyph before the first connection. */
204209 readonly descriptor: ExecutionEnvironmentDescriptor | undefined;
205 readonly borderTop: boolean;
206210 readonly errorExpanded: boolean;
207211 readonly onSetEnabled: (enabled: boolean) => void;
208212 readonly onRemove: () => void;
213 readonly onOpen?: (() => void) | undefined;
209214 readonly onToggleError: () => void;
210215}) {
211216 const serverConfig = useAtomValue(
......@@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: {
222227 return (
223228 <Pressable
224229 accessibilityHint="Long press to remove from this device"
230 accessibilityRole={props.onOpen ? "button" : undefined}
231 accessibilityLabel={props.onOpen ? `Manage ${props.environment.environmentLabel}` : undefined}
232 onPress={props.onOpen}
225233 onLongPress={props.onRemove}
226234 >
227235 <CloudEnvironmentRowShell
228 borderTop={props.borderTop}
236 opensDetails={props.onOpen !== undefined}
229237 connectionError={enabled || unsupported ? props.environment.connectionError : null}
230238 connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null}
231239 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
......@@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: {
246254
247255function CloudEnvironmentRow(props: {
248256 readonly environment: RelayEnvironmentView;
249 readonly borderTop: boolean;
257 readonly showChevron: boolean;
250258 readonly errorExpanded: boolean;
251259 readonly onConnect: () => void;
252260 readonly onToggleError: () => void;
......@@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: {
260268
261269 return (
262270 <CloudEnvironmentRowShell
263 borderTop={props.borderTop}
271 showChevron={props.showChevron}
264272 connectionError={presentation.connectionError}
265273 connectionErrorTraceId={presentation.connectionErrorTraceId}
266274 connectionState={presentation.connectionState}
......@@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: {
285293}
286294
287295function CloudEnvironmentRowShell(props: {
288 readonly borderTop: boolean;
296 readonly showChevron?: boolean;
297 readonly opensDetails?: boolean;
289298 readonly connectionError: string | null;
290299 readonly connectionErrorTraceId: string | null;
291300 readonly connectionState: EnvironmentConnectionPhase;
......@@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: {
340349 [measuredErrorText, props.connectionError],
341350 );
342351 return (
343 <View
344 collapsable={false}
345 className={cn(
346 "flex-row items-center gap-3 bg-card px-4 py-3.5",
347 props.borderTop && "border-t border-border",
348 )}
349 >
352 <View collapsable={false} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5">
350353 <View className="min-w-0 flex-1 gap-0.5">
351354 <View className="min-w-0 flex-row items-center gap-2">
352355 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
......@@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: {
409412 </StatusContainer>
410413 </View>
411414 <ThemedSwitch
415 style={{ alignSelf: "center" }}
412416 disabled={props.disabled}
413417 onValueChange={props.onValueChange}
414418 value={props.value}
415419 />
420 {props.opensDetails || props.showChevron ? (
421 <View style={{ opacity: props.opensDetails ? 1 : 0.4 }}>
422 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
423 </View>
424 ) : null}
416425 </View>
417426 );
418427}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-11
......@@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string
3535export function ConnectionEnvironmentRow(props: {
3636 readonly environment: ConnectedEnvironmentSummary;
3737 readonly expanded: boolean;
38 readonly opensDetails?: boolean;
3839 readonly onToggle: () => void;
3940 readonly onReconnect: (environmentId: EnvironmentId) => void;
4041 readonly onRemove: (environmentId: EnvironmentId) => void;
......@@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: {
7677 }, [label, url, props]);
7778
7879 return (
79 <Animated.View layout={LinearTransition.duration(250)} className="bg-card">
80 <Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card">
8081 <Pressable
8182 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
83 accessibilityRole="button"
84 accessibilityLabel={
85 props.opensDetails ? `Manage ${props.environment.environmentLabel}` : undefined
86 }
8287 onPress={props.onToggle}
8388 >
84 <ConnectionStatusDot
85 state={enabled || unsupported ? props.environment.connectionState : "available"}
86 pulse={isRetrying}
87 size={8}
88 />
89
9089 <View className="flex-1 gap-0.5">
9190 <View className="flex-row items-center gap-1.5">
91 <ConnectionStatusDot
92 state={enabled || unsupported ? props.environment.connectionState : "available"}
93 pulse={isRetrying}
94 size={8}
95 />
9296 <EnvironmentMachineSymbol
9397 kind={resolveEnvironmentMachineKind(serverConfig)}
9498 size={14}
......@@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: {
101105 {props.environment.environmentLabel}
102106 </Text>
103107 </View>
104 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
105 {props.environment.displayUrl}
106 </Text>
108 {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? (
109 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
110 {props.environment.displayUrl}
111 </Text>
112 ) : null}
107113 {statusLabel ? (
108114 <Text
109115 className={cn(
......@@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: {
126132 </View>
127133
128134 <ThemedSwitch
135 style={{ alignSelf: "center" }}
129136 disabled={unsupported}
130137 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
131138 value={enabled}
132139 />
133140 <SymbolView
134 name="chevron.down"
141 name={props.opensDetails ? "chevron.right" : "chevron.down"}
135142 size={12}
136143 tintColorClassName="accent-icon-subtle"
137144 type="monochrome"
apps/mobile/src/features/connection/GitHubRoutingSettings.tsx+3-6
......@@ -43,15 +43,12 @@ export function GitHubRoutingSettings() {
4343 return (
4444 <View className="mt-5 gap-3">
4545 <SettingsSection title="GitHub routing">
46 {[...catalog.entries.values()].map((entry, index) => {
46 {[...catalog.entries.values()].map((entry) => {
4747 const environmentId = entry.target.environmentId;
4848 const selected = gitHubRoutingPermissionFor(entry, permissions);
4949 const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null;
5050 return (
51 <View
52 key={environmentId}
53 className={index === 0 ? undefined : "border-t border-border-subtle"}
54 >
51 <View key={environmentId}>
5552 <Pressable
5653 accessibilityRole="button"
5754 accessibilityLabel={`${entry.target.label} GitHub routing`}
......@@ -83,7 +80,7 @@ export function GitHubRoutingSettings() {
8380 accessibilityRole="radio"
8481 accessibilityState={{ checked: selected === option.value, disabled }}
8582 disabled={disabled}
86 className="flex-row items-center gap-4 border-t border-border-subtle p-4 disabled:opacity-50"
83 className="flex-row items-center gap-4 p-4 disabled:opacity-50"
8784 onPress={() => {
8885 setSaving(true);
8986 void update({ environmentId, permission: option.value }).then((result) => {
apps/mobile/src/features/connection/LocalEnvironmentList.tsx+11-10
......@@ -4,7 +4,6 @@ import { View } from "react-native";
44
55import { SymbolView } from "../../components/AppSymbol";
66import { AppText as Text } from "../../components/AppText";
7import { cn } from "../../lib/cn";
87import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow";
98
109type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>;
......@@ -15,14 +14,20 @@ export function LocalEnvironmentList({
1514 expandedId,
1615 onToggle,
1716 ...rowActions
18}: Pick<EnvironmentRowProps, "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate"> & {
17}: Pick<
18 EnvironmentRowProps,
19 "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails"
20> & {
1921 readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>;
2022 readonly expandedId: EnvironmentId | null;
2123 readonly onToggle: (environmentId: EnvironmentId) => void;
2224}) {
2325 if (environments.length === 0) {
2426 return (
25 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
27 <View
28 collapsable={false}
29 className="items-center gap-3 rounded-[24px] bg-grouped-card px-6 py-8"
30 >
2631 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
2732 <SymbolView
2833 name="point.3.connected.trianglepath.dotted"
......@@ -40,13 +45,9 @@ export function LocalEnvironmentList({
4045 }
4146
4247 return (
43 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
44 {environments.map((environment, index) => (
45 <View
46 key={environment.environmentId}
47 collapsable={false}
48 className={cn(index !== 0 && "border-t border-border")}
49 >
48 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card">
49 {environments.map((environment) => (
50 <View key={environment.environmentId} collapsable={false}>
5051 <ConnectionEnvironmentRow
5152 environment={environment}
5253 expanded={expandedId === environment.environmentId}
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx created+341
......@@ -0,0 +1,341 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { StaticScreenProps } from "@react-navigation/native";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import { AsyncResult } from "effect/unstable/reactivity";
6import { useEffect, useRef, useState } from "react";
7import { Alert, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../components/AppText";
11import { ProviderIcon } from "../../components/ProviderIcon";
12import { ScreenScrollView } from "../../components/ScreenScrollView";
13import { serverEnvironment } from "../../state/server";
14import { environmentSession } from "../../state/session";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { useRemoteConnections } from "../../state/use-remote-environment-registry";
17import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
18import { SettingsActionRow } from "./components/SettingsActionRow";
19import { SettingsScreen } from "./components/SettingsScreen";
20import { SettingsSection } from "./components/SettingsSection";
21import {
22 canMaintainEnvironment,
23 canUpdateEnvironmentProvider,
24 findEnvironmentUpdate,
25 supportsEnvironmentUpdate,
26} from "./environment-maintenance";
27
28export function SettingsEnvironmentDetailRouteScreen({
29 route,
30}: StaticScreenProps<{
31 readonly environmentId: EnvironmentId;
32}>) {
33 // Key local request state to the host even when navigation reuses this screen.
34 return (
35 <EnvironmentDetail
36 key={route.params.environmentId}
37 environmentId={route.params.environmentId}
38 />
39 );
40}
41
42function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) {
43 const insets = useSafeAreaInsets();
44 const connections = useRemoteConnections();
45 const environment = connections.connectedEnvironments.find(
46 (entry) => entry.environmentId === environmentId,
47 );
48 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
49 const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId));
50 const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId));
51 const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
52 const updateServer = useAtomCommand(serverEnvironment.updateServer);
53 const updateProvider = useAtomCommand(serverEnvironment.updateProvider);
54 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders);
55 const [connectionExpanded, setConnectionExpanded] = useState(false);
56 const [pending, setPending] = useState<string | null>(null);
57 const pendingRef = useRef(false);
58 const [error, setError] = useState<string | null>(null);
59 const [notice, setNotice] = useState<string | null>(null);
60 const [release, setRelease] = useState<{
61 fromVersion: string;
62 targetVersion: string | null;
63 } | null>(null);
64 const checkController = useRef<AbortController | null>(null);
65 useEffect(() => () => checkController.current?.abort(), []);
66
67 const connected = environment?.isEnabled === true && environment.connectionState === "connected";
68 const allowed =
69 !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected);
70 const running = updateState.status === "running";
71 const providerBusy =
72 config?.providers.some(
73 (provider) =>
74 provider.updateState?.status === "running" || provider.updateState?.status === "queued",
75 ) ?? false;
76 const disabled = !allowed || pending !== null || running || providerBusy;
77 const version = config?.environment.serverVersion;
78 const checkedRelease = release?.fromVersion === version ? release : null;
79 const capabilities = config?.environment.capabilities;
80
81 async function run(label: string, action: () => Promise<void>) {
82 if (pendingRef.current) return;
83 pendingRef.current = true;
84 setPending(label);
85 setError(null);
86 setNotice(null);
87 try {
88 await action();
89 } catch (cause) {
90 setError(
91 cause instanceof Error ? cause.message : "The action could not be completed. Try again.",
92 );
93 } finally {
94 pendingRef.current = false;
95 setPending(null);
96 }
97 }
98
99 function requestServerUpdate() {
100 const targetVersion = checkedRelease?.targetVersion;
101 if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities))
102 return;
103 Alert.alert(
104 `Update ${environment?.environmentLabel ?? "environment"}?`,
105 `Install T3 Code ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`,
106 [
107 { text: "Cancel", style: "cancel" },
108 {
109 text: "Update",
110 onPress: () =>
111 void run("server", async () => {
112 const result = await updateServer({
113 environmentId,
114 input: {
115 targetVersion,
116 ...(capabilities.serverUpdateThreadContinuation &&
117 config?.settings.continueThreadsAfterServerUpdate
118 ? { continueRunningThreads: true }
119 : {}),
120 },
121 });
122 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
123 setRelease(null);
124 setNotice(`Updated to ${result.value.targetVersion}.`);
125 }),
126 },
127 ],
128 );
129 }
130
131 function requestProviderUpdate(provider: ServerProvider) {
132 if (disabled || !canUpdateEnvironmentProvider(provider)) return;
133 void run(provider.instanceId, async () => {
134 const result = await updateProvider({
135 environmentId,
136 input: {
137 provider: provider.driver,
138 instanceId: provider.instanceId,
139 },
140 });
141 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
142 });
143 }
144
145 return (
146 <SettingsScreen title={environment?.environmentLabel ?? "Environment"}>
147 <ScreenScrollView
148 contentInsetAdjustmentBehavior="automatic"
149 className="flex-1"
150 contentContainerClassName="gap-6 px-5 pt-4"
151 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
152 >
153 {!environment ? (
154 <Text className="text-base text-foreground-muted">
155 This environment is no longer saved on this device.
156 </Text>
157 ) : (
158 <>
159 <SettingsSection title="Connection">
160 <ConnectionEnvironmentRow
161 environment={environment}
162 expanded={connectionExpanded}
163 onToggle={() => setConnectionExpanded((value) => !value)}
164 onReconnect={connections.onReconnectEnvironment}
165 onRemove={connections.onRemoveEnvironmentPress}
166 onSetEnabled={connections.onSetEnvironmentEnabled}
167 onUpdate={connections.onUpdateEnvironment}
168 />
169 </SettingsSection>
170 {!connected ? (
171 <Text className="px-2 text-sm text-foreground-muted">
172 Connect this environment to manage it.
173 </Text>
174 ) : !allowed ? (
175 <Text className="px-2 text-sm text-foreground-muted">
176 {AsyncResult.isFailure(sessionResult)
177 ? "Could not verify your permissions. Reconnect to try again."
178 : session === null
179 ? "Checking permissions…"
180 : "This connection does not have permission to manage the environment."}
181 </Text>
182 ) : null}
183 {error ? (
184 <Text selectable className="px-2 text-sm text-danger-foreground">
185 {error}
186 </Text>
187 ) : null}
188 {notice ? <Text className="px-2 text-sm text-foreground-muted">{notice}</Text> : null}
189 {config ? (
190 <>
191 <SettingsSection title="T3 Code">
192 <View className="gap-1 p-4">
193 <Text className="text-base text-foreground">Version {version}</Text>
194 {running ? (
195 <Text className="text-sm text-foreground-muted">
196 {updateState.stage === "resuming"
197 ? "Restarting and reconnecting…"
198 : "Downloading update…"}
199 </Text>
200 ) : updateState.status === "failed" ? (
201 <Text selectable className="text-sm text-danger-foreground">
202 {updateState.message}
203 </Text>
204 ) : null}
205 {checkedRelease ? (
206 <Text className="text-sm text-foreground-muted">
207 {checkedRelease.targetVersion
208 ? `Version ${checkedRelease.targetVersion} is available.`
209 : "You are up to date."}
210 </Text>
211 ) : null}
212 {!supportsEnvironmentUpdate(config.environment.capabilities) ? (
213 <Text className="text-sm text-foreground-muted">
214 {capabilities?.serverSelfUpdate === "desktop-managed"
215 ? "Update the desktop app on this machine."
216 : "Update and restart T3 Code on this machine."}
217 </Text>
218 ) : null}
219 </View>
220 <SettingsActionRow
221 icon="arrow.clockwise"
222 label="Check for updates"
223 disabled={disabled}
224 loading={pending === "check"}
225 onPress={() => {
226 if (disabled) return;
227 void run("check", async () => {
228 const controller = new AbortController();
229 checkController.current = controller;
230 const timeout = setTimeout(() => controller.abort(), 20_000);
231 try {
232 const targetVersion = await findEnvironmentUpdate(
233 config.environment.serverVersion,
234 controller.signal,
235 );
236 setRelease({
237 fromVersion: config.environment.serverVersion,
238 targetVersion,
239 });
240 } finally {
241 clearTimeout(timeout);
242 checkController.current = null;
243 }
244 });
245 }}
246 />
247 {checkedRelease?.targetVersion &&
248 supportsEnvironmentUpdate(config.environment.capabilities) ? (
249 <SettingsActionRow
250 icon="arrow.up.circle"
251 label={`Update to ${checkedRelease.targetVersion}`}
252 disabled={disabled}
253 loading={pending === "server" || running}
254 onPress={requestServerUpdate}
255 />
256 ) : null}
257 </SettingsSection>
258 <SettingsSection title="Providers">
259 <SettingsActionRow
260 icon="arrow.clockwise"
261 label="Refresh providers"
262 disabled={disabled}
263 loading={pending === "refresh"}
264 onPress={() => {
265 if (disabled) return;
266 void run("refresh", async () => {
267 const result = await refreshProviders({ environmentId, input: {} });
268 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
269 setNotice("Provider status refreshed.");
270 });
271 }}
272 />
273 {config.providers
274 .filter((provider) => provider.enabled)
275 .map((provider) => (
276 <View key={provider.instanceId}>
277 <View className="gap-1 p-4">
278 <View className="flex-row items-center gap-2">
279 <ProviderIcon provider={provider.driver} size={18} />
280 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground">
281 {provider.displayName ?? provider.driver}
282 </Text>
283 </View>
284 <Text className="text-sm text-foreground-muted">
285 {provider.installed
286 ? (provider.version ?? "Version unknown")
287 : "Not installed"}
288 {provider.versionAdvisory?.latestVersion
289 ? ` · Latest ${provider.versionAdvisory.latestVersion}`
290 : ""}
291 </Text>
292 {provider.updateState && provider.updateState.status !== "idle" ? (
293 <Text
294 selectable
295 className={
296 provider.updateState.status === "failed"
297 ? "text-sm text-danger-foreground"
298 : "text-sm text-foreground-muted"
299 }
300 >
301 {provider.updateState.message ??
302 `Update ${provider.updateState.status}`}
303 </Text>
304 ) : null}
305 {provider.compatibilityAdvisory?.message ? (
306 <Text selectable className="text-sm text-foreground-muted">
307 {provider.compatibilityAdvisory.message}
308 </Text>
309 ) : null}
310 {provider.unavailableReason || provider.message ? (
311 <Text selectable className="text-sm text-foreground-muted">
312 {provider.unavailableReason ?? provider.message}
313 </Text>
314 ) : null}
315 {provider.versionAdvisory?.status === "behind_latest" &&
316 !provider.versionAdvisory.canUpdate ? (
317 <Text className="text-sm text-foreground-muted">
318 Update this provider on the environment's machine.
319 </Text>
320 ) : null}
321 </View>
322 {canUpdateEnvironmentProvider(provider) ? (
323 <SettingsActionRow
324 icon="arrow.up.circle"
325 label={`Update ${provider.displayName ?? provider.driver}`}
326 disabled={disabled}
327 loading={pending === provider.instanceId}
328 onPress={() => requestProviderUpdate(provider)}
329 />
330 ) : null}
331 </View>
332 ))}
333 </SettingsSection>
334 </>
335 ) : null}
336 </>
337 )}
338 </ScreenScrollView>
339 </SettingsScreen>
340 );
341}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+13-6
......@@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
4747 const connectedCloudEnvironments = SHOWCASE_ENABLED
4848 ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS
4949 : environmentSections.connectedCloudEnvironments;
50 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
5150 const headerIconColor = useUniwindTheme()["--color-icon"];
5251 const relaySession = useAtomValue(managedRelaySessionAtom);
5352 const refreshRelayEnvironments = useAtomCommand(
......@@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() {
6867 }
6968 }
7069
71 const handleToggle = useCallback((environmentId: EnvironmentId) => {
72 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
73 }, []);
70 const openEnvironment = useCallback(
71 (environmentId: EnvironmentId) => {
72 navigation.navigate("SettingsSheet", {
73 screen: "SettingsContent",
74 params: { screen: "SettingsEnvironmentDetail", params: { environmentId } },
75 });
76 },
77 [navigation],
78 );
7479 const handleUpdateEnvironment = useCallback(
7580 (
7681 environmentId: EnvironmentId,
......@@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() {
159164 >
160165 <LocalEnvironmentList
161166 environments={localEnvironments}
162 expandedId={expandedId}
163 onToggle={handleToggle}
167 expandedId={null}
168 onToggle={openEnvironment}
169 opensDetails
164170 onReconnect={onReconnectEnvironment}
165171 onRemove={onRemoveEnvironmentPress}
166172 onSetEnabled={onSetEnvironmentEnabled}
......@@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() {
172178 user is signed out — the component gates discovery itself. */}
173179 <CloudEnvironmentRows
174180 connectedCloudEnvironments={connectedCloudEnvironments}
181 onOpenEnvironment={openEnvironment}
175182 onSetEnvironmentEnabled={onSetEnvironmentEnabled}
176183 onRemoveEnvironment={onRemoveEnvironmentPress}
177184 {...(SHOWCASE_ENABLED
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+54-26
......@@ -1,3 +1,5 @@
1import { useNavigation } from "@react-navigation/native";
2import { SettingsRow } from "./components/SettingsRow";
13import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
24import { SymbolView } from "../../components/AppSymbol";
35import { AppText as Text } from "../../components/AppText";
......@@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() {
133135
134136function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
135137 const insets = useSafeAreaInsets();
138 const navigation = useNavigation();
136139 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
137140 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
138141 const projectSelected = selectedProjectKey !== null;
......@@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
397400 ) : null}
398401
399402 {props.page === "maintenance" ? (
400 <SettingsSection title="Updates">
401 <FanoutSwitchRow
402 icon="arrow.clockwise"
403 label="Check provider updates"
404 subtitle={
405 projectSelected
406 ? "Environment-wide setting. Select All projects to change it."
407 : "Check installed provider CLIs for newer versions."
408 }
409 value={uniform("enableProviderUpdateChecks")}
410 disabled={disabledFor("enableProviderUpdateChecks")}
411 onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
412 />
413 <View className="border-t border-border-subtle">
403 <>
404 {!projectSelected ? (
405 <SettingsSection title="Manage environments">
406 {selectedTargets.map((target) => (
407 <SettingsRow
408 key={target.environmentId}
409 icon="server.rack"
410 label={target.label}
411 value="Server and provider updates"
412 onPress={() =>
413 navigation.navigate("SettingsSheet", {
414 screen: "SettingsContent",
415 params: {
416 screen: "SettingsEnvironmentDetail",
417 params: { environmentId: target.environmentId },
418 },
419 })
420 }
421 />
422 ))}
423 </SettingsSection>
424 ) : null}
425 <SettingsSection title="Updates">
414426 <FanoutSwitchRow
415 icon="arrow.uturn.forward"
416 label="Continue after restart"
427 icon="arrow.clockwise"
428 label="Check provider updates"
417429 subtitle={
418 supportsContinuation
419 ? "Resume interrupted threads after an update or restart."
420 : "Update older servers to control restart continuation."
421 }
422 value={uniform("continueThreadsAfterServerUpdate")}
423 disabled={
424 disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation
430 projectSelected
431 ? "Environment-wide setting. Select All projects to change it."
432 : "Check installed provider CLIs for newer versions."
425433 }
426 onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })}
434 value={uniform("enableProviderUpdateChecks")}
435 disabled={disabledFor("enableProviderUpdateChecks")}
436 onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
427437 />
428 </View>
429 </SettingsSection>
438 <View className="border-t border-border-subtle">
439 <FanoutSwitchRow
440 icon="arrow.uturn.forward"
441 label="Continue after restart"
442 subtitle={
443 supportsContinuation
444 ? "Resume interrupted threads after an update or restart."
445 : "Update older servers to control restart continuation."
446 }
447 value={uniform("continueThreadsAfterServerUpdate")}
448 disabled={
449 disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation
450 }
451 onValueChange={(value) =>
452 write({ continueThreadsAfterServerUpdate: value })
453 }
454 />
455 </View>
456 </SettingsSection>
457 </>
430458 ) : null}
431459 </>
432460 )}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1
......@@ -17,6 +17,7 @@ export function SettingsSwitchRow(
1717 subtitle={props.subtitle}
1818 >
1919 <ThemedSwitch
20 style={{ alignSelf: "center" }}
2021 accessibilityLabel={props.label}
2122 disabled={props.disabled}
2223 onValueChange={props.onValueChange}
apps/mobile/src/features/settings/environment-maintenance.test.ts created+154
......@@ -0,0 +1,154 @@
1import { AuthOrchestrationOperateScope, ServerProvider } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 canMaintainEnvironment,
7 canUpdateEnvironmentProvider,
8 findEnvironmentUpdate,
9 supportsEnvironmentUpdate,
10} from "./environment-maintenance";
11
12const provider = Schema.decodeUnknownSync(ServerProvider)({
13 instanceId: "codex-personal",
14 driver: "codex",
15 enabled: true,
16 installed: true,
17 version: "1.0.0",
18 status: "ready",
19 auth: { status: "authenticated" },
20 checkedAt: "2026-09-23T00:00:00.000Z",
21 models: [],
22 versionAdvisory: {
23 status: "behind_latest",
24 currentVersion: "1.0.0",
25 latestVersion: "1.1.0",
26 canUpdate: true,
27 updateCommand: null,
28 checkedAt: null,
29 message: null,
30 },
31});
32
33afterEach(() => vi.unstubAllGlobals());
34
35describe("environment maintenance access", () => {
36 it("requires a connected authenticated session with operate permission", () => {
37 const session = {
38 authenticated: true,
39 auth: {
40 policy: "remote-reachable" as const,
41 bootstrapMethods: [],
42 sessionMethods: [],
43 sessionCookieName: "session",
44 },
45 scopes: [AuthOrchestrationOperateScope],
46 };
47 expect(canMaintainEnvironment(session, true)).toBe(true);
48 expect(canMaintainEnvironment(session, false)).toBe(false);
49 expect(canMaintainEnvironment({ ...session, authenticated: false }, true)).toBe(false);
50 expect(canMaintainEnvironment({ ...session, scopes: [] }, true)).toBe(false);
51 const { scopes: _, ...legacy } = session;
52 expect(canMaintainEnvironment(legacy, true)).toBe(false);
53 expect(canMaintainEnvironment(null, true)).toBe(false);
54 });
55
56 it("requires remote desktop update support for desktop hosts", () => {
57 expect(supportsEnvironmentUpdate({})).toBe(false);
58 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
59 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed" })).toBe(false);
60 expect(
61 supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed", desktopAppUpdate: true }),
62 ).toBe(true);
63 });
64
65 it("excludes unavailable, manual, busy, and incompatible provider updates", () => {
66 expect(canUpdateEnvironmentProvider(provider)).toBe(true);
67 expect(canUpdateEnvironmentProvider({ ...provider, installed: false })).toBe(false);
68 expect(canUpdateEnvironmentProvider({ ...provider, availability: "unavailable" })).toBe(false);
69 expect(canUpdateEnvironmentProvider({ ...provider, versionAdvisory: undefined })).toBe(false);
70 for (const latestVersionStatus of ["broken", "unsupported"] as const) {
71 expect(
72 canUpdateEnvironmentProvider({
73 ...provider,
74 compatibilityAdvisory: {
75 status: "supported",
76 latestVersionStatus,
77 message: null,
78 recommendedVersion: null,
79 recommendedRange: null,
80 },
81 }),
82 ).toBe(false);
83 }
84 for (const status of ["queued", "running"] as const) {
85 expect(
86 canUpdateEnvironmentProvider({
87 ...provider,
88 updateState: {
89 status,
90 startedAt: null,
91 finishedAt: null,
92 message: null,
93 output: null,
94 },
95 }),
96 ).toBe(false);
97 }
98 expect(
99 canUpdateEnvironmentProvider({
100 ...provider,
101 versionAdvisory: {
102 ...provider.versionAdvisory!,
103 canUpdate: false,
104 },
105 }),
106 ).toBe(false);
107 });
108});
109
110describe("environment release checks", () => {
111 const signal = new AbortController().signal;
112
113 it("keeps stable hosts on stable releases and ignores drafts", async () => {
114 vi.stubGlobal(
115 "fetch",
116 vi
117 .fn()
118 .mockImplementation(async () =>
119 Response.json([
120 { tag_name: "v2.0.0-nightly.20260923.1" },
121 { tag_name: "v1.2.0", draft: true },
122 { tag_name: "v1.1.0" },
123 ]),
124 ),
125 );
126 expect(await findEnvironmentUpdate("1.0.0", signal)).toBe("1.1.0");
127 expect(await findEnvironmentUpdate("1.1.0", signal)).toBeNull();
128 expect(await findEnvironmentUpdate("1.3.0", signal)).toBeNull();
129 });
130
131 it("walks release pages to find the host's channel", async () => {
132 const fetchMock = vi
133 .fn()
134 .mockResolvedValueOnce(
135 Response.json(Array.from({ length: 100 }, () => ({ tag_name: "v2.0.0" }))),
136 )
137 .mockResolvedValueOnce(Response.json([{ tag_name: "v1.0.0-preview.20260923.2" }]));
138 vi.stubGlobal("fetch", fetchMock);
139 expect(await findEnvironmentUpdate("1.0.0-preview.20260923.1", signal)).toBe(
140 "1.0.0-preview.20260923.2",
141 );
142 expect(fetchMock.mock.calls[1]?.[0]).toContain("page=2");
143 expect(fetchMock.mock.calls[1]?.[1]).toEqual({ signal });
144 });
145
146 it("reports failed checks instead of claiming the server is current", async () => {
147 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 403 })));
148 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("403");
149 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json([])));
150 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("No stable release");
151 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "invalid" })));
152 await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow();
153 });
154});
apps/mobile/src/features/settings/environment-maintenance.ts created+68
......@@ -0,0 +1,68 @@
1import {
2 AuthOrchestrationOperateScope,
3 type AuthSessionState,
4 type ExecutionEnvironmentCapabilities,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import {
8 cliReleaseChannelOf,
9 cliReleaseIndexPageUrl,
10 newestCliReleaseVersion,
11} from "@t3tools/shared/cliRelease";
12import { compareSemverVersions } from "@t3tools/shared/semver";
13import * as Schema from "effect/Schema";
14
15export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) {
16 return (
17 connected &&
18 session?.authenticated === true &&
19 session.scopes?.includes(AuthOrchestrationOperateScope) === true
20 );
21}
22
23export function supportsEnvironmentUpdate(
24 capabilities: Pick<ExecutionEnvironmentCapabilities, "serverSelfUpdate" | "desktopAppUpdate">,
25) {
26 return (
27 capabilities.serverSelfUpdate !== undefined &&
28 (capabilities.serverSelfUpdate !== "desktop-managed" || capabilities.desktopAppUpdate === true)
29 );
30}
31
32export function canUpdateEnvironmentProvider(provider: ServerProvider) {
33 const compatibility = provider.compatibilityAdvisory?.latestVersionStatus;
34 return (
35 provider.installed &&
36 provider.availability !== "unavailable" &&
37 provider.versionAdvisory?.status === "behind_latest" &&
38 provider.versionAdvisory.canUpdate &&
39 provider.versionAdvisory.latestVersion !== null &&
40 compatibility !== "broken" &&
41 compatibility !== "unsupported" &&
42 provider.updateState?.status !== "running" &&
43 provider.updateState?.status !== "queued"
44 );
45}
46
47const Releases = Schema.Array(
48 Schema.Struct({
49 tag_name: Schema.String,
50 draft: Schema.optionalKey(Schema.Boolean),
51 }),
52);
53const decodeReleases = Schema.decodeUnknownSync(Releases);
54
55/** Preserve the host's release channel and never offer a downgrade. */
56export async function findEnvironmentUpdate(currentVersion: string, signal: AbortSignal) {
57 const channel = cliReleaseChannelOf(currentVersion);
58 for (let page = 1; ; page++) {
59 const response = await fetch(cliReleaseIndexPageUrl(page), { signal });
60 if (!response.ok) throw new Error(`Could not check releases (${response.status}). Try again.`);
61 const releases = decodeReleases(await response.json());
62 const version = newestCliReleaseVersion(releases, channel);
63 if (version !== undefined) {
64 return compareSemverVersions(version, currentVersion) > 0 ? version : null;
65 }
66 if (releases.length < 100) throw new Error(`No ${channel} release was found.`);
67 }
68}
docs/user/updating.md+12
......@@ -58,6 +58,18 @@ update can roll back to the previous version. If the update still fails:
5858
5959## Mobile updates
6060
61To update an environment from your phone, open **Settings → Environments** and
62select it. **Check for updates** finds the latest release on that environment's
63current release channel. Keep the app open while the environment updates and
64reconnects. Hosts that cannot update remotely show instructions for updating on
65the machine instead.
66
67The same page lets you refresh provider status and update supported providers.
68These controls require a connected environment and permission to operate it.
69Provider update checks and restart continuation preferences are in
70**Settings → Maintenance**. If provider update checks are disabled, enable them
71there before refreshing to find newer versions.
72
6173Install App Store or Google Play releases as usual. The mobile app can also
6274download updates in the background and apply them when you next leave the app.
6375It saves drafts and queued messages before restarting. If you keep the app open