| author | |
| committer | |
| log | 68fb7f4b850991ba0ca5bd0d7c4aa13d4e5d6bb7 |
| tree | dc256aa25fd89f0d29253d9f33b2c36b74f23bfc |
| parent | 11e91f126405883c0194da5b1e3ad39d81c48966 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 723 insertions(+), 80 deletions(-)
.github/workflows/mobile-eas-preview.yml+13-1| ... | @@ -78,7 +78,19 @@ jobs: | ... | @@ -78,7 +78,19 @@ jobs: |
| 78 | working-directory: apps/mobile | 78 | working-directory: apps/mobile |
| 79 | env: | 79 | env: |
| 80 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | 80 | 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 | ||
| 82 | 94 | ||
| 83 | - name: Deploy with fingerprint check | 95 | - name: Deploy with fingerprint check |
| 84 | if: steps.expo-token.outputs.present == 'true' | 96 | if: steps.expo-token.outputs.present == 'true' |
apps/mobile/src/Stack.tsx+6| ... | @@ -71,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea | ... | @@ -71,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea |
| 71 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; | 71 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 72 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; | 72 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; |
| 73 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 73 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 74 | import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; | ||
| 74 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 75 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 75 | import { | 76 | import { |
| 76 | SettingsEnvironmentAgentBehaviorRouteScreen, | 77 | SettingsEnvironmentAgentBehaviorRouteScreen, |
| ... | @@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -193,6 +194,11 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 193 | title: "Environments", | 194 | title: "Environments", |
| 194 | }, | 195 | }, |
| 195 | }), | 196 | }), |
| 197 | SettingsEnvironmentDetail: createNativeStackScreen({ | ||
| 198 | screen: SettingsEnvironmentDetailRouteScreen, | ||
| 199 | linking: "environments/:environmentId", | ||
| 200 | options: { title: "Environment" }, | ||
| 201 | }), | ||
| 196 | SettingsEnvironmentNewThreads: createNativeStackScreen({ | 202 | SettingsEnvironmentNewThreads: createNativeStackScreen({ |
| 197 | screen: SettingsEnvironmentNewThreadsRouteScreen, | 203 | screen: SettingsEnvironmentNewThreadsRouteScreen, |
| 198 | linking: "new-threads", | 204 | linking: "new-threads", |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+29-20| ... | @@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect | ... | @@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect |
| 35 | 35 | ||
| 36 | interface CloudEnvironmentRowsProps { | 36 | interface CloudEnvironmentRowsProps { |
| 37 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | 37 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 38 | readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void; | ||
| 38 | readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; | 39 | readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 39 | /** Long-press on a saved row. The callback owns the confirm. */ | 40 | /** Long-press on a saved row. The callback owns the confirm. */ |
| 40 | readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; | 41 | readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; |
| ... | @@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent( | ... | @@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent( |
| 124 | ) : null} | 125 | ) : null} |
| 125 | 126 | ||
| 126 | {hasCloudRows ? ( | 127 | {hasCloudRows ? ( |
| 127 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 128 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card"> |
| 128 | {props.connectedCloudEnvironments.map((environment, index) => ( | 129 | {props.connectedCloudEnvironments.map((environment) => ( |
| 129 | <ConnectedCloudEnvironmentRow | 130 | <ConnectedCloudEnvironmentRow |
| 130 | key={environment.environmentId} | 131 | key={environment.environmentId} |
| 131 | environment={environment} | 132 | environment={environment} |
| 132 | descriptor={discoveredDescriptors.get(environment.environmentId)} | 133 | descriptor={discoveredDescriptors.get(environment.environmentId)} |
| 133 | borderTop={index !== 0} | ||
| 134 | onSetEnabled={(enabled) => | 134 | onSetEnabled={(enabled) => |
| 135 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) | 135 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) |
| 136 | } | 136 | } |
| 137 | onRemove={() => props.onRemoveEnvironment(environment.environmentId)} | 137 | onRemove={() => props.onRemoveEnvironment(environment.environmentId)} |
| 138 | onOpen={ | ||
| 139 | props.onOpenEnvironment | ||
| 140 | ? () => props.onOpenEnvironment?.(environment.environmentId) | ||
| 141 | : undefined | ||
| 142 | } | ||
| 138 | errorExpanded={expandedErrorId === environment.environmentId} | 143 | errorExpanded={expandedErrorId === environment.environmentId} |
| 139 | onToggleError={() => handleToggleCloudError(environment.environmentId)} | 144 | onToggleError={() => handleToggleCloudError(environment.environmentId)} |
| 140 | /> | 145 | /> |
| 141 | ))} | 146 | ))} |
| 142 | {availableCloudEnvironments.map((environment, index) => ( | 147 | {availableCloudEnvironments.map((environment) => ( |
| 143 | <CloudEnvironmentRow | 148 | <CloudEnvironmentRow |
| 144 | key={environment.environment.environmentId} | 149 | key={environment.environment.environmentId} |
| 145 | environment={environment} | 150 | environment={environment} |
| 146 | borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0} | 151 | showChevron={props.onOpenEnvironment !== undefined} |
| 147 | onConnect={() => handleConnectCloudEnvironment(environment)} | 152 | onConnect={() => handleConnectCloudEnvironment(environment)} |
| 148 | errorExpanded={expandedErrorId === environment.environment.environmentId} | 153 | errorExpanded={expandedErrorId === environment.environment.environmentId} |
| 149 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} | 154 | onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} |
| ... | @@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent( | ... | @@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent( |
| 151 | ))} | 156 | ))} |
| 152 | </View> | 157 | </View> |
| 153 | ) : controller.relayDiscovery.isRefreshing ? ( | 158 | ) : 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"> |
| 155 | <ActivityIndicator colorClassName={"accent-icon"} /> | 160 | <ActivityIndicator colorClassName={"accent-icon"} /> |
| 156 | <Text className="text-center text-sm leading-normal text-foreground-muted"> | 161 | <Text className="text-center text-sm leading-normal text-foreground-muted"> |
| 157 | Loading linked cloud environments. | 162 | Loading linked cloud environments. |
| 158 | </Text> | 163 | </Text> |
| 159 | </View> | 164 | </View> |
| 160 | ) : controller.relayDiscovery.error ? null : ( | 165 | ) : 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"> |
| 162 | <Text className="text-sm leading-normal text-foreground-muted"> | 167 | <Text className="text-sm leading-normal text-foreground-muted"> |
| 163 | No additional linked cloud environments. | 168 | No additional linked cloud environments. |
| 164 | </Text> | 169 | </Text> |
| ... | @@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent( | ... | @@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent( |
| 170 | {discoveryAvailable && | 175 | {discoveryAvailable && |
| 171 | controller.relayDiscovery.error && | 176 | controller.relayDiscovery.error && |
| 172 | !controller.relayDiscovery.isRefreshing ? ( | 177 | !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"> |
| 174 | <Text className="text-base font-t3-bold text-foreground"> | 179 | <Text className="text-base font-t3-bold text-foreground"> |
| 175 | Could not load T3 Connect environments | 180 | Could not load T3 Connect environments |
| 176 | </Text> | 181 | </Text> |
| ... | @@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: { | ... | @@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: { |
| 202 | readonly environment: ConnectedEnvironmentSummary; | 207 | readonly environment: ConnectedEnvironmentSummary; |
| 203 | /** Discovery's view of the server, for the glyph before the first connection. */ | 208 | /** Discovery's view of the server, for the glyph before the first connection. */ |
| 204 | readonly descriptor: ExecutionEnvironmentDescriptor | undefined; | 209 | readonly descriptor: ExecutionEnvironmentDescriptor | undefined; |
| 205 | readonly borderTop: boolean; | ||
| 206 | readonly errorExpanded: boolean; | 210 | readonly errorExpanded: boolean; |
| 207 | readonly onSetEnabled: (enabled: boolean) => void; | 211 | readonly onSetEnabled: (enabled: boolean) => void; |
| 208 | readonly onRemove: () => void; | 212 | readonly onRemove: () => void; |
| 213 | readonly onOpen?: (() => void) | undefined; | ||
| 209 | readonly onToggleError: () => void; | 214 | readonly onToggleError: () => void; |
| 210 | }) { | 215 | }) { |
| 211 | const serverConfig = useAtomValue( | 216 | const serverConfig = useAtomValue( |
| ... | @@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: { | ... | @@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: { |
| 222 | return ( | 227 | return ( |
| 223 | <Pressable | 228 | <Pressable |
| 224 | accessibilityHint="Long press to remove from this device" | 229 | 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} | ||
| 225 | onLongPress={props.onRemove} | 233 | onLongPress={props.onRemove} |
| 226 | > | 234 | > |
| 227 | <CloudEnvironmentRowShell | 235 | <CloudEnvironmentRowShell |
| 228 | borderTop={props.borderTop} | 236 | opensDetails={props.onOpen !== undefined} |
| 229 | connectionError={enabled || unsupported ? props.environment.connectionError : null} | 237 | connectionError={enabled || unsupported ? props.environment.connectionError : null} |
| 230 | connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} | 238 | connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} |
| 231 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} | 239 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} |
| ... | @@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: { | ... | @@ -246,7 +254,7 @@ function ConnectedCloudEnvironmentRow(props: { |
| 246 | 254 | ||
| 247 | function CloudEnvironmentRow(props: { | 255 | function CloudEnvironmentRow(props: { |
| 248 | readonly environment: RelayEnvironmentView; | 256 | readonly environment: RelayEnvironmentView; |
| 249 | readonly borderTop: boolean; | 257 | readonly showChevron: boolean; |
| 250 | readonly errorExpanded: boolean; | 258 | readonly errorExpanded: boolean; |
| 251 | readonly onConnect: () => void; | 259 | readonly onConnect: () => void; |
| 252 | readonly onToggleError: () => void; | 260 | readonly onToggleError: () => void; |
| ... | @@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: { | ... | @@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: { |
| 260 | 268 | ||
| 261 | return ( | 269 | return ( |
| 262 | <CloudEnvironmentRowShell | 270 | <CloudEnvironmentRowShell |
| 263 | borderTop={props.borderTop} | 271 | showChevron={props.showChevron} |
| 264 | connectionError={presentation.connectionError} | 272 | connectionError={presentation.connectionError} |
| 265 | connectionErrorTraceId={presentation.connectionErrorTraceId} | 273 | connectionErrorTraceId={presentation.connectionErrorTraceId} |
| 266 | connectionState={presentation.connectionState} | 274 | connectionState={presentation.connectionState} |
| ... | @@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: { | ... | @@ -285,7 +293,8 @@ function CloudEnvironmentRow(props: { |
| 285 | } | 293 | } |
| 286 | 294 | ||
| 287 | function CloudEnvironmentRowShell(props: { | 295 | function CloudEnvironmentRowShell(props: { |
| 288 | readonly borderTop: boolean; | 296 | readonly showChevron?: boolean; |
| 297 | readonly opensDetails?: boolean; | ||
| 289 | readonly connectionError: string | null; | 298 | readonly connectionError: string | null; |
| 290 | readonly connectionErrorTraceId: string | null; | 299 | readonly connectionErrorTraceId: string | null; |
| 291 | readonly connectionState: EnvironmentConnectionPhase; | 300 | readonly connectionState: EnvironmentConnectionPhase; |
| ... | @@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -340,13 +349,7 @@ function CloudEnvironmentRowShell(props: { |
| 340 | [measuredErrorText, props.connectionError], | 349 | [measuredErrorText, props.connectionError], |
| 341 | ); | 350 | ); |
| 342 | return ( | 351 | return ( |
| 343 | <View | 352 | <View collapsable={false} className="flex-row items-center gap-3 bg-grouped-card px-4 py-3.5"> |
| 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 | > | ||
| 350 | <View className="min-w-0 flex-1 gap-0.5"> | 353 | <View className="min-w-0 flex-1 gap-0.5"> |
| 351 | <View className="min-w-0 flex-row items-center gap-2"> | 354 | <View className="min-w-0 flex-row items-center gap-2"> |
| 352 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> | 355 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| ... | @@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: { |
| 409 | </StatusContainer> | 412 | </StatusContainer> |
| 410 | </View> | 413 | </View> |
| 411 | <ThemedSwitch | 414 | <ThemedSwitch |
| 415 | style={{ alignSelf: "center" }} | ||
| 412 | disabled={props.disabled} | 416 | disabled={props.disabled} |
| 413 | onValueChange={props.onValueChange} | 417 | onValueChange={props.onValueChange} |
| 414 | value={props.value} | 418 | value={props.value} |
| 415 | /> | 419 | /> |
| 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} | ||
| 416 | </View> | 425 | </View> |
| 417 | ); | 426 | ); |
| 418 | } | 427 | } |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-11| ... | @@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | ... | @@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string |
| 35 | export function ConnectionEnvironmentRow(props: { | 35 | export function ConnectionEnvironmentRow(props: { |
| 36 | readonly environment: ConnectedEnvironmentSummary; | 36 | readonly environment: ConnectedEnvironmentSummary; |
| 37 | readonly expanded: boolean; | 37 | readonly expanded: boolean; |
| 38 | readonly opensDetails?: boolean; | ||
| 38 | readonly onToggle: () => void; | 39 | readonly onToggle: () => void; |
| 39 | readonly onReconnect: (environmentId: EnvironmentId) => void; | 40 | readonly onReconnect: (environmentId: EnvironmentId) => void; |
| 40 | readonly onRemove: (environmentId: EnvironmentId) => void; | 41 | readonly onRemove: (environmentId: EnvironmentId) => void; |
| ... | @@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: { |
| 76 | }, [label, url, props]); | 77 | }, [label, url, props]); |
| 77 | 78 | ||
| 78 | return ( | 79 | return ( |
| 79 | <Animated.View layout={LinearTransition.duration(250)} className="bg-card"> | 80 | <Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card"> |
| 80 | <Pressable | 81 | <Pressable |
| 81 | className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70" | 82 | 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 | } | ||
| 82 | onPress={props.onToggle} | 87 | onPress={props.onToggle} |
| 83 | > | 88 | > |
| 84 | <ConnectionStatusDot | ||
| 85 | state={enabled || unsupported ? props.environment.connectionState : "available"} | ||
| 86 | pulse={isRetrying} | ||
| 87 | size={8} | ||
| 88 | /> | ||
| 89 | |||
| 90 | <View className="flex-1 gap-0.5"> | 89 | <View className="flex-1 gap-0.5"> |
| 91 | <View className="flex-row items-center gap-1.5"> | 90 | <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 | /> | ||
| 92 | <EnvironmentMachineSymbol | 96 | <EnvironmentMachineSymbol |
| 93 | kind={resolveEnvironmentMachineKind(serverConfig)} | 97 | kind={resolveEnvironmentMachineKind(serverConfig)} |
| 94 | size={14} | 98 | size={14} |
| ... | @@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -101,9 +105,11 @@ export function ConnectionEnvironmentRow(props: { |
| 101 | {props.environment.environmentLabel} | 105 | {props.environment.environmentLabel} |
| 102 | </Text> | 106 | </Text> |
| 103 | </View> | 107 | </View> |
| 104 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> | 108 | {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? ( |
| 105 | {props.environment.displayUrl} | 109 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> |
| 106 | </Text> | 110 | {props.environment.displayUrl} |
| 111 | </Text> | ||
| 112 | ) : null} | ||
| 107 | {statusLabel ? ( | 113 | {statusLabel ? ( |
| 108 | <Text | 114 | <Text |
| 109 | className={cn( | 115 | className={cn( |
| ... | @@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -126,12 +132,13 @@ export function ConnectionEnvironmentRow(props: { |
| 126 | </View> | 132 | </View> |
| 127 | 133 | ||
| 128 | <ThemedSwitch | 134 | <ThemedSwitch |
| 135 | style={{ alignSelf: "center" }} | ||
| 129 | disabled={unsupported} | 136 | disabled={unsupported} |
| 130 | onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)} | 137 | onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)} |
| 131 | value={enabled} | 138 | value={enabled} |
| 132 | /> | 139 | /> |
| 133 | <SymbolView | 140 | <SymbolView |
| 134 | name="chevron.down" | 141 | name={props.opensDetails ? "chevron.right" : "chevron.down"} |
| 135 | size={12} | 142 | size={12} |
| 136 | tintColorClassName="accent-icon-subtle" | 143 | tintColorClassName="accent-icon-subtle" |
| 137 | type="monochrome" | 144 | type="monochrome" |
apps/mobile/src/features/connection/GitHubRoutingSettings.tsx+3-6| ... | @@ -43,15 +43,12 @@ export function GitHubRoutingSettings() { | ... | @@ -43,15 +43,12 @@ export function GitHubRoutingSettings() { |
| 43 | return ( | 43 | return ( |
| 44 | <View className="mt-5 gap-3"> | 44 | <View className="mt-5 gap-3"> |
| 45 | <SettingsSection title="GitHub routing"> | 45 | <SettingsSection title="GitHub routing"> |
| 46 | {[...catalog.entries.values()].map((entry, index) => { | 46 | {[...catalog.entries.values()].map((entry) => { |
| 47 | const environmentId = entry.target.environmentId; | 47 | const environmentId = entry.target.environmentId; |
| 48 | const selected = gitHubRoutingPermissionFor(entry, permissions); | 48 | const selected = gitHubRoutingPermissionFor(entry, permissions); |
| 49 | const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; | 49 | const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; |
| 50 | return ( | 50 | return ( |
| 51 | <View | 51 | <View key={environmentId}> |
| 52 | key={environmentId} | ||
| 53 | className={index === 0 ? undefined : "border-t border-border-subtle"} | ||
| 54 | > | ||
| 55 | <Pressable | 52 | <Pressable |
| 56 | accessibilityRole="button" | 53 | accessibilityRole="button" |
| 57 | accessibilityLabel={`${entry.target.label} GitHub routing`} | 54 | accessibilityLabel={`${entry.target.label} GitHub routing`} |
| ... | @@ -83,7 +80,7 @@ export function GitHubRoutingSettings() { | ... | @@ -83,7 +80,7 @@ export function GitHubRoutingSettings() { |
| 83 | accessibilityRole="radio" | 80 | accessibilityRole="radio" |
| 84 | accessibilityState={{ checked: selected === option.value, disabled }} | 81 | accessibilityState={{ checked: selected === option.value, disabled }} |
| 85 | disabled={disabled} | 82 | 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" |
| 87 | onPress={() => { | 84 | onPress={() => { |
| 88 | setSaving(true); | 85 | setSaving(true); |
| 89 | void update({ environmentId, permission: option.value }).then((result) => { | 86 | 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"; | ... | @@ -4,7 +4,6 @@ import { View } from "react-native"; |
| 4 | 4 | ||
| 5 | import { SymbolView } from "../../components/AppSymbol"; | 5 | import { SymbolView } from "../../components/AppSymbol"; |
| 6 | import { AppText as Text } from "../../components/AppText"; | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { cn } from "../../lib/cn"; | ||
| 8 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; | 7 | import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; |
| 9 | 8 | ||
| 10 | type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>; | 9 | type EnvironmentRowProps = ComponentProps<typeof ConnectionEnvironmentRow>; |
| ... | @@ -15,14 +14,20 @@ export function LocalEnvironmentList({ | ... | @@ -15,14 +14,20 @@ export function LocalEnvironmentList({ |
| 15 | expandedId, | 14 | expandedId, |
| 16 | onToggle, | 15 | onToggle, |
| 17 | ...rowActions | 16 | ...rowActions |
| 18 | }: Pick<EnvironmentRowProps, "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate"> & { | 17 | }: Pick< |
| 18 | EnvironmentRowProps, | ||
| 19 | "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails" | ||
| 20 | > & { | ||
| 19 | readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>; | 21 | readonly environments: ReadonlyArray<EnvironmentRowProps["environment"]>; |
| 20 | readonly expandedId: EnvironmentId | null; | 22 | readonly expandedId: EnvironmentId | null; |
| 21 | readonly onToggle: (environmentId: EnvironmentId) => void; | 23 | readonly onToggle: (environmentId: EnvironmentId) => void; |
| 22 | }) { | 24 | }) { |
| 23 | if (environments.length === 0) { | 25 | if (environments.length === 0) { |
| 24 | return ( | 26 | 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 | > | ||
| 26 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | 31 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> |
| 27 | <SymbolView | 32 | <SymbolView |
| 28 | name="point.3.connected.trianglepath.dotted" | 33 | name="point.3.connected.trianglepath.dotted" |
| ... | @@ -40,13 +45,9 @@ export function LocalEnvironmentList({ | ... | @@ -40,13 +45,9 @@ export function LocalEnvironmentList({ |
| 40 | } | 45 | } |
| 41 | 46 | ||
| 42 | return ( | 47 | return ( |
| 43 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 48 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-grouped-card"> |
| 44 | {environments.map((environment, index) => ( | 49 | {environments.map((environment) => ( |
| 45 | <View | 50 | <View key={environment.environmentId} collapsable={false}> |
| 46 | key={environment.environmentId} | ||
| 47 | collapsable={false} | ||
| 48 | className={cn(index !== 0 && "border-t border-border")} | ||
| 49 | > | ||
| 50 | <ConnectionEnvironmentRow | 51 | <ConnectionEnvironmentRow |
| 51 | environment={environment} | 52 | environment={environment} |
| 52 | expanded={expandedId === environment.environmentId} | 53 | expanded={expandedId === environment.environmentId} |
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx created+341| ... | @@ -0,0 +1,341 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { StaticScreenProps } from "@react-navigation/native"; | ||
| 3 | import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; | ||
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 6 | import { useEffect, useRef, useState } from "react"; | ||
| 7 | import { Alert, View } from "react-native"; | ||
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 9 | |||
| 10 | import { AppText as Text } from "../../components/AppText"; | ||
| 11 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 12 | import { ScreenScrollView } from "../../components/ScreenScrollView"; | ||
| 13 | import { serverEnvironment } from "../../state/server"; | ||
| 14 | import { environmentSession } from "../../state/session"; | ||
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 16 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 17 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | ||
| 18 | import { SettingsActionRow } from "./components/SettingsActionRow"; | ||
| 19 | import { SettingsScreen } from "./components/SettingsScreen"; | ||
| 20 | import { SettingsSection } from "./components/SettingsSection"; | ||
| 21 | import { | ||
| 22 | canMaintainEnvironment, | ||
| 23 | canUpdateEnvironmentProvider, | ||
| 24 | findEnvironmentUpdate, | ||
| 25 | supportsEnvironmentUpdate, | ||
| 26 | } from "./environment-maintenance"; | ||
| 27 | |||
| 28 | export 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 | |||
| 42 | function 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() { | ... | @@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { |
| 47 | const connectedCloudEnvironments = SHOWCASE_ENABLED | 47 | const connectedCloudEnvironments = SHOWCASE_ENABLED |
| 48 | ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS | 48 | ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS |
| 49 | : environmentSections.connectedCloudEnvironments; | 49 | : environmentSections.connectedCloudEnvironments; |
| 50 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 51 | const headerIconColor = useUniwindTheme()["--color-icon"]; | 50 | const headerIconColor = useUniwindTheme()["--color-icon"]; |
| 52 | const relaySession = useAtomValue(managedRelaySessionAtom); | 51 | const relaySession = useAtomValue(managedRelaySessionAtom); |
| 53 | const refreshRelayEnvironments = useAtomCommand( | 52 | const refreshRelayEnvironments = useAtomCommand( |
| ... | @@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() { |
| 68 | } | 67 | } |
| 69 | } | 68 | } |
| 70 | 69 | ||
| 71 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | 70 | const openEnvironment = useCallback( |
| 72 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | 71 | (environmentId: EnvironmentId) => { |
| 73 | }, []); | 72 | navigation.navigate("SettingsSheet", { |
| 73 | screen: "SettingsContent", | ||
| 74 | params: { screen: "SettingsEnvironmentDetail", params: { environmentId } }, | ||
| 75 | }); | ||
| 76 | }, | ||
| 77 | [navigation], | ||
| 78 | ); | ||
| 74 | const handleUpdateEnvironment = useCallback( | 79 | const handleUpdateEnvironment = useCallback( |
| 75 | ( | 80 | ( |
| 76 | environmentId: EnvironmentId, | 81 | environmentId: EnvironmentId, |
| ... | @@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() { |
| 159 | > | 164 | > |
| 160 | <LocalEnvironmentList | 165 | <LocalEnvironmentList |
| 161 | environments={localEnvironments} | 166 | environments={localEnvironments} |
| 162 | expandedId={expandedId} | 167 | expandedId={null} |
| 163 | onToggle={handleToggle} | 168 | onToggle={openEnvironment} |
| 169 | opensDetails | ||
| 164 | onReconnect={onReconnectEnvironment} | 170 | onReconnect={onReconnectEnvironment} |
| 165 | onRemove={onRemoveEnvironmentPress} | 171 | onRemove={onRemoveEnvironmentPress} |
| 166 | onSetEnabled={onSetEnvironmentEnabled} | 172 | onSetEnabled={onSetEnvironmentEnabled} |
| ... | @@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -172,6 +178,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 172 | user is signed out — the component gates discovery itself. */} | 178 | user is signed out — the component gates discovery itself. */} |
| 173 | <CloudEnvironmentRows | 179 | <CloudEnvironmentRows |
| 174 | connectedCloudEnvironments={connectedCloudEnvironments} | 180 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 181 | onOpenEnvironment={openEnvironment} | ||
| 175 | onSetEnvironmentEnabled={onSetEnvironmentEnabled} | 182 | onSetEnvironmentEnabled={onSetEnvironmentEnabled} |
| 176 | onRemoveEnvironment={onRemoveEnvironmentPress} | 183 | onRemoveEnvironment={onRemoveEnvironmentPress} |
| 177 | {...(SHOWCASE_ENABLED | 184 | {...(SHOWCASE_ENABLED |
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+54-26| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import { SettingsRow } from "./components/SettingsRow"; | ||
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 3 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 4 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() { | ... | @@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() { |
| 133 | 135 | ||
| 134 | function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | 136 | function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 135 | const insets = useSafeAreaInsets(); | 137 | const insets = useSafeAreaInsets(); |
| 138 | const navigation = useNavigation(); | ||
| 136 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); | 139 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 137 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); | 140 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 138 | const projectSelected = selectedProjectKey !== null; | 141 | const projectSelected = selectedProjectKey !== null; |
| ... | @@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | ... | @@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { |
| 397 | ) : null} | 400 | ) : null} |
| 398 | 401 | ||
| 399 | {props.page === "maintenance" ? ( | 402 | {props.page === "maintenance" ? ( |
| 400 | <SettingsSection title="Updates"> | 403 | <> |
| 401 | <FanoutSwitchRow | 404 | {!projectSelected ? ( |
| 402 | icon="arrow.clockwise" | 405 | <SettingsSection title="Manage environments"> |
| 403 | label="Check provider updates" | 406 | {selectedTargets.map((target) => ( |
| 404 | subtitle={ | 407 | <SettingsRow |
| 405 | projectSelected | 408 | key={target.environmentId} |
| 406 | ? "Environment-wide setting. Select All projects to change it." | 409 | icon="server.rack" |
| 407 | : "Check installed provider CLIs for newer versions." | 410 | label={target.label} |
| 408 | } | 411 | value="Server and provider updates" |
| 409 | value={uniform("enableProviderUpdateChecks")} | 412 | onPress={() => |
| 410 | disabled={disabledFor("enableProviderUpdateChecks")} | 413 | navigation.navigate("SettingsSheet", { |
| 411 | onValueChange={(value) => write({ enableProviderUpdateChecks: value })} | 414 | screen: "SettingsContent", |
| 412 | /> | 415 | params: { |
| 413 | <View className="border-t border-border-subtle"> | 416 | screen: "SettingsEnvironmentDetail", |
| 417 | params: { environmentId: target.environmentId }, | ||
| 418 | }, | ||
| 419 | }) | ||
| 420 | } | ||
| 421 | /> | ||
| 422 | ))} | ||
| 423 | </SettingsSection> | ||
| 424 | ) : null} | ||
| 425 | <SettingsSection title="Updates"> | ||
| 414 | <FanoutSwitchRow | 426 | <FanoutSwitchRow |
| 415 | icon="arrow.uturn.forward" | 427 | icon="arrow.clockwise" |
| 416 | label="Continue after restart" | 428 | label="Check provider updates" |
| 417 | subtitle={ | 429 | subtitle={ |
| 418 | supportsContinuation | 430 | projectSelected |
| 419 | ? "Resume interrupted threads after an update or restart." | 431 | ? "Environment-wide setting. Select All projects to change it." |
| 420 | : "Update older servers to control restart continuation." | 432 | : "Check installed provider CLIs for newer versions." |
| 421 | } | ||
| 422 | value={uniform("continueThreadsAfterServerUpdate")} | ||
| 423 | disabled={ | ||
| 424 | disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation | ||
| 425 | } | 433 | } |
| 426 | onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })} | 434 | value={uniform("enableProviderUpdateChecks")} |
| 435 | disabled={disabledFor("enableProviderUpdateChecks")} | ||
| 436 | onValueChange={(value) => write({ enableProviderUpdateChecks: value })} | ||
| 427 | /> | 437 | /> |
| 428 | </View> | 438 | <View className="border-t border-border-subtle"> |
| 429 | </SettingsSection> | 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 | </> | ||
| 430 | ) : null} | 458 | ) : null} |
| 431 | </> | 459 | </> |
| 432 | )} | 460 | )} |
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1| ... | @@ -17,6 +17,7 @@ export function SettingsSwitchRow( | ... | @@ -17,6 +17,7 @@ export function SettingsSwitchRow( |
| 17 | subtitle={props.subtitle} | 17 | subtitle={props.subtitle} |
| 18 | > | 18 | > |
| 19 | <ThemedSwitch | 19 | <ThemedSwitch |
| 20 | style={{ alignSelf: "center" }} | ||
| 20 | accessibilityLabel={props.label} | 21 | accessibilityLabel={props.label} |
| 21 | disabled={props.disabled} | 22 | disabled={props.disabled} |
| 22 | onValueChange={props.onValueChange} | 23 | onValueChange={props.onValueChange} |
apps/mobile/src/features/settings/environment-maintenance.test.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { AuthOrchestrationOperateScope, ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | canMaintainEnvironment, | ||
| 7 | canUpdateEnvironmentProvider, | ||
| 8 | findEnvironmentUpdate, | ||
| 9 | supportsEnvironmentUpdate, | ||
| 10 | } from "./environment-maintenance"; | ||
| 11 | |||
| 12 | const 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 | |||
| 33 | afterEach(() => vi.unstubAllGlobals()); | ||
| 34 | |||
| 35 | describe("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 | |||
| 110 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | AuthOrchestrationOperateScope, | ||
| 3 | type AuthSessionState, | ||
| 4 | type ExecutionEnvironmentCapabilities, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { | ||
| 8 | cliReleaseChannelOf, | ||
| 9 | cliReleaseIndexPageUrl, | ||
| 10 | newestCliReleaseVersion, | ||
| 11 | } from "@t3tools/shared/cliRelease"; | ||
| 12 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | |||
| 15 | export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) { | ||
| 16 | return ( | ||
| 17 | connected && | ||
| 18 | session?.authenticated === true && | ||
| 19 | session.scopes?.includes(AuthOrchestrationOperateScope) === true | ||
| 20 | ); | ||
| 21 | } | ||
| 22 | |||
| 23 | export 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 | |||
| 32 | export 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 | |||
| 47 | const Releases = Schema.Array( | ||
| 48 | Schema.Struct({ | ||
| 49 | tag_name: Schema.String, | ||
| 50 | draft: Schema.optionalKey(Schema.Boolean), | ||
| 51 | }), | ||
| 52 | ); | ||
| 53 | const decodeReleases = Schema.decodeUnknownSync(Releases); | ||
| 54 | |||
| 55 | /** Preserve the host's release channel and never offer a downgrade. */ | ||
| 56 | export 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: | ... | @@ -58,6 +58,18 @@ update can roll back to the previous version. If the update still fails: |
| 58 | 58 | ||
| 59 | ## Mobile updates | 59 | ## Mobile updates |
| 60 | 60 | ||
| 61 | To update an environment from your phone, open **Settings → Environments** and | ||
| 62 | select it. **Check for updates** finds the latest release on that environment's | ||
| 63 | current release channel. Keep the app open while the environment updates and | ||
| 64 | reconnects. Hosts that cannot update remotely show instructions for updating on | ||
| 65 | the machine instead. | ||
| 66 | |||
| 67 | The same page lets you refresh provider status and update supported providers. | ||
| 68 | These controls require a connected environment and permission to operate it. | ||
| 69 | Provider update checks and restart continuation preferences are in | ||
| 70 | **Settings → Maintenance**. If provider update checks are disabled, enable them | ||
| 71 | there before refreshing to find newer versions. | ||
| 72 | |||
| 61 | Install App Store or Google Play releases as usual. The mobile app can also | 73 | Install App Store or Google Play releases as usual. The mobile app can also |
| 62 | download updates in the background and apply them when you next leave the app. | 74 | download updates in the background and apply them when you next leave the app. |
| 63 | It saves drafts and queued messages before restarting. If you keep the app open | 75 | It saves drafts and queued messages before restarting. If you keep the app open |