| author | |
| committer | |
| log | c25c3e0ee678ef62245cb5eef20eeafb9a86d9d9 |
| tree | 7a450ec29acb59d6088c4ec6a205204afdd20221 |
| parent | 2d8378b4404888f0d5458e8fc9c83b1032e21290 |
| signature | Signed by PGP key B5690EEEBB952194 |
27 files changed, 2382 insertions(+), 816 deletions(-)
apps/mobile/src/Stack.tsx+63-2| ... | ... | @@ -59,6 +59,12 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl |
| 59 | 59 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; |
| 60 | 60 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 61 | 61 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 62 | import { | |
| 63 | SettingsEnvironmentAgentBehaviorRouteScreen, | |
| 64 | SettingsEnvironmentMaintenanceRouteScreen, | |
| 65 | SettingsEnvironmentNewThreadsRouteScreen, | |
| 66 | SettingsEnvironmentSourceControlRouteScreen, | |
| 67 | } from "./features/settings/SettingsServerControlsRouteScreen"; | |
| 62 | 68 | import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; |
| 63 | 69 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 64 | 70 | import { |
| ... | ... | @@ -66,9 +72,14 @@ import { |
| 66 | 72 | SettingsOpenSourceLicensesRouteScreen, |
| 67 | 73 | } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; |
| 68 | 74 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 75 | import { SettingsProjectOverviewRouteScreen } from "./features/settings/SettingsProjectOverviewRouteScreen"; | |
| 69 | 76 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; |
| 70 | 77 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 78 | import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; | |
| 79 | import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen"; | |
| 71 | 80 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 81 | import { SettingsThreadsRouteScreen } from "./features/settings/SettingsThreadsRouteScreen"; | |
| 82 | import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; | |
| 72 | 83 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 73 | 84 | import { |
| 74 | 85 | SettingsLegalDocumentCloseHeaderButton, |
| ... | ... | @@ -168,6 +179,41 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 168 | 179 | title: "Environments", |
| 169 | 180 | }, |
| 170 | 181 | }), |
| 182 | SettingsEnvironmentNewThreads: createNativeStackScreen({ | |
| 183 | screen: SettingsEnvironmentNewThreadsRouteScreen, | |
| 184 | linking: "new-threads", | |
| 185 | options: { title: "New threads" }, | |
| 186 | }), | |
| 187 | SettingsEnvironmentSourceControl: createNativeStackScreen({ | |
| 188 | screen: SettingsEnvironmentSourceControlRouteScreen, | |
| 189 | linking: "source-control", | |
| 190 | options: { title: "Source control" }, | |
| 191 | }), | |
| 192 | SettingsEnvironmentAgentBehavior: createNativeStackScreen({ | |
| 193 | screen: SettingsEnvironmentAgentBehaviorRouteScreen, | |
| 194 | linking: "agent-behavior", | |
| 195 | options: { title: "Agent behavior" }, | |
| 196 | }), | |
| 197 | SettingsEnvironmentMaintenance: createNativeStackScreen({ | |
| 198 | screen: SettingsEnvironmentMaintenanceRouteScreen, | |
| 199 | linking: "maintenance", | |
| 200 | options: { title: "Maintenance" }, | |
| 201 | }), | |
| 202 | SettingsNotifications: createNativeStackScreen({ | |
| 203 | screen: SettingsNotificationsRouteScreen, | |
| 204 | linking: "notifications", | |
| 205 | options: { title: "Notifications" }, | |
| 206 | }), | |
| 207 | SettingsThreads: createNativeStackScreen({ | |
| 208 | screen: SettingsThreadsRouteScreen, | |
| 209 | linking: "thread-preferences", | |
| 210 | options: { title: "Thread behavior" }, | |
| 211 | }), | |
| 212 | SettingsAbout: createNativeStackScreen({ | |
| 213 | screen: SettingsAboutRouteScreen, | |
| 214 | linking: "about", | |
| 215 | options: { title: "About T3 Code" }, | |
| 216 | }), | |
| 171 | 217 | SettingsEnvironmentNew: createNativeStackScreen({ |
| 172 | 218 | screen: ConnectionsNewRouteScreen, |
| 173 | 219 | linking: "environment-new", |
| ... | ... | @@ -193,9 +239,21 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 193 | 239 | screen: SettingsProjectGroupingRouteScreen, |
| 194 | 240 | linking: "project-grouping", |
| 195 | 241 | options: { |
| 196 | title: "Project Grouping", | |
| 242 | title: "Organization", | |
| 197 | 243 | }, |
| 198 | 244 | }), |
| 245 | SettingsOrganization: createNativeStackScreen({ | |
| 246 | screen: SettingsProjectGroupingRouteScreen, | |
| 247 | linking: "organization", | |
| 248 | options: { | |
| 249 | title: "Organization", | |
| 250 | }, | |
| 251 | }), | |
| 252 | SettingsProjectOverview: createNativeStackScreen({ | |
| 253 | screen: SettingsProjectOverviewRouteScreen, | |
| 254 | linking: "project", | |
| 255 | options: { title: "Project overview" }, | |
| 256 | }), | |
| 199 | 257 | SettingsKeyboard: createNativeStackScreen({ |
| 200 | 258 | screen: SettingsKeyboardRouteScreen, |
| 201 | 259 | linking: "keyboard", |
| ... | ... | @@ -257,6 +315,9 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 257 | 315 | SettingsContent: createNativeStackScreen({ |
| 258 | 316 | screen: SettingsContentStack, |
| 259 | 317 | linking: "", |
| 318 | layout: ({ children }) => ( | |
| 319 | <SettingsEnvironmentFilterProvider>{children}</SettingsEnvironmentFilterProvider> | |
| 320 | ), | |
| 260 | 321 | }), |
| 261 | 322 | SettingsAuth: createNativeStackScreen({ |
| 262 | 323 | screen: SettingsAuthRouteScreen, |
| ... | ... | @@ -632,7 +693,7 @@ export const RootStack = createNativeStackNavigator({ |
| 632 | 693 | ? { presentation: "card" as const } |
| 633 | 694 | : { |
| 634 | 695 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 635 | sheetAllowedDetents: [0.7, 0.92], | |
| 696 | sheetAllowedDetents: [0.92], | |
| 636 | 697 | sheetGrabberVisible: true, |
| 637 | 698 | }), |
| 638 | 699 | }, |
apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx created+149| ... | ... | @@ -0,0 +1,149 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 2 | import Constants from "expo-constants"; | |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | import { Alert, Pressable, View } from "react-native"; | |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 6 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | |
| 9 | import { | |
| 10 | type AppUpdateCheckState, | |
| 11 | isAppUpdateCheckAvailable, | |
| 12 | registerHiddenUpdateTap, | |
| 13 | runAppUpdateCheck, | |
| 14 | } from "../updates/app-updates"; | |
| 15 | import { SettingsRow } from "./components/SettingsRow"; | |
| 16 | import { SettingsSection } from "./components/SettingsSection"; | |
| 17 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 18 | ||
| 19 | export function SettingsAboutRouteScreen() { | |
| 20 | const insets = useSafeAreaInsets(); | |
| 21 | ||
| 22 | return ( | |
| 23 | <SettingsScreen title="About T3 Code"> | |
| 24 | <ScrollView | |
| 25 | contentInsetAdjustmentBehavior="automatic" | |
| 26 | showsVerticalScrollIndicator={false} | |
| 27 | className="flex-1" | |
| 28 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 29 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 30 | > | |
| 31 | <AppSettingsSection /> | |
| 32 | </ScrollView> | |
| 33 | </SettingsScreen> | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | function AppSettingsSection() { | |
| 38 | const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle"); | |
| 39 | const updateInFlight = useRef(false); | |
| 40 | const hiddenUpdateTapCount = useRef(0); | |
| 41 | ||
| 42 | const version = Constants.expoConfig?.version ?? "0.0.0"; | |
| 43 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | |
| 44 | // missing variant never mislabels a production build as development. | |
| 45 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | |
| 46 | const variantLabel = variant === "production" ? "" : capitalize(variant); | |
| 47 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; | |
| 48 | const updateCheckAvailable = isAppUpdateCheckAvailable(); | |
| 49 | const busy = | |
| 50 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; | |
| 51 | ||
| 52 | // "Up to date" is a transient acknowledgement, not a state worth persisting — | |
| 53 | // return the version row to its normal, deliberately quiet state. | |
| 54 | useEffect(() => { | |
| 55 | if (updateState !== "current") return; | |
| 56 | const timer = setTimeout(() => setUpdateState("idle"), 3000); | |
| 57 | return () => clearTimeout(timer); | |
| 58 | }, [updateState]); | |
| 59 | ||
| 60 | const checkForUpdate = useCallback(async () => { | |
| 61 | // `disabled={busy}` only takes effect on the next render, so two taps in the | |
| 62 | // same frame would both get through. The ref closes that window. | |
| 63 | if (updateInFlight.current) return; | |
| 64 | updateInFlight.current = true; | |
| 65 | try { | |
| 66 | // The user asked for this restart by tapping the version row, so it may | |
| 67 | // apply immediately instead of prompting. | |
| 68 | await runAppUpdateCheck({ | |
| 69 | applyMode: "immediate", | |
| 70 | onFailure: (message) => Alert.alert("Update failed", message), | |
| 71 | onStateChange: setUpdateState, | |
| 72 | }); | |
| 73 | } finally { | |
| 74 | updateInFlight.current = false; | |
| 75 | } | |
| 76 | }, []); | |
| 77 | ||
| 78 | const handleVersionPress = useCallback(() => { | |
| 79 | if (!updateCheckAvailable || updateInFlight.current) return; | |
| 80 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); | |
| 81 | hiddenUpdateTapCount.current = tap.nextCount; | |
| 82 | if (tap.shouldCheck) { | |
| 83 | void checkForUpdate(); | |
| 84 | } | |
| 85 | }, [checkForUpdate, updateCheckAvailable]); | |
| 86 | ||
| 87 | const statusLabel = | |
| 88 | updateState === "checking" | |
| 89 | ? "Checking…" | |
| 90 | : updateState === "downloading" | |
| 91 | ? "Downloading…" | |
| 92 | : // "ready" appears only when this check joined an in-flight background-mode | |
| 93 | // check; that download installs at the next backgrounding. | |
| 94 | updateState === "ready" | |
| 95 | ? "Update ready" | |
| 96 | : updateState === "restarting" | |
| 97 | ? "Restarting…" | |
| 98 | : updateState === "current" | |
| 99 | ? "Up to date" | |
| 100 | : null; | |
| 101 | ||
| 102 | const versionRow = ( | |
| 103 | <View className="flex-row items-center gap-4 p-4"> | |
| 104 | <SymbolView | |
| 105 | name="info.circle" | |
| 106 | size={22} | |
| 107 | tintColorClassName="accent-icon" | |
| 108 | type="monochrome" | |
| 109 | weight="regular" | |
| 110 | /> | |
| 111 | <Text className="flex-1 text-lg text-foreground">Version</Text> | |
| 112 | <View className="items-end"> | |
| 113 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> | |
| 114 | {statusLabel ? ( | |
| 115 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> | |
| 116 | ) : null} | |
| 117 | </View> | |
| 118 | </View> | |
| 119 | ); | |
| 120 | ||
| 121 | return ( | |
| 122 | <SettingsSection title="App"> | |
| 123 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | |
| 124 | <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" /> | |
| 125 | <SettingsRow | |
| 126 | icon="doc.on.doc" | |
| 127 | label="Open source licenses" | |
| 128 | target="SettingsOpenSourceLicenses" | |
| 129 | /> | |
| 130 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | |
| 131 | {updateCheckAvailable ? ( | |
| 132 | <Pressable | |
| 133 | accessibilityLabel={`Version ${versionLabel}`} | |
| 134 | accessibilityRole="text" | |
| 135 | disabled={busy} | |
| 136 | onPress={handleVersionPress} | |
| 137 | > | |
| 138 | {versionRow} | |
| 139 | </Pressable> | |
| 140 | ) : ( | |
| 141 | versionRow | |
| 142 | )} | |
| 143 | </SettingsSection> | |
| 144 | ); | |
| 145 | } | |
| 146 | ||
| 147 | function capitalize(value: string): string { | |
| 148 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; | |
| 149 | } |
apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx created+499| ... | ... | @@ -0,0 +1,499 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 2 | import { useAuth } from "@clerk/expo"; | |
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | import * as Notifications from "expo-notifications"; | |
| 5 | import { useNavigation } from "@react-navigation/native"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 8 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; | |
| 9 | import { Alert, AppState, Linking, Platform } from "react-native"; | |
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 11 | ||
| 12 | import { AppText as Text } from "../../components/AppText"; | |
| 13 | import { | |
| 14 | isAtomCommandInterrupted, | |
| 15 | reportAtomCommandResult, | |
| 16 | settleAsyncResult, | |
| 17 | settlePromise, | |
| 18 | squashAtomCommandFailure, | |
| 19 | } from "@t3tools/client-runtime/state/runtime"; | |
| 20 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; | |
| 21 | import { | |
| 22 | openAndroidLiveUpdateSettings, | |
| 23 | supportsAndroidLiveUpdateSettings, | |
| 24 | } from "../agent-awareness/androidNotifications"; | |
| 25 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; | |
| 26 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; | |
| 27 | import { | |
| 28 | getAgentAwarenessRegistrationStatus, | |
| 29 | refreshAgentAwarenessRegistration, | |
| 30 | subscribeAgentAwarenessRegistrationStatus, | |
| 31 | } from "../agent-awareness/remoteRegistration"; | |
| 32 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | |
| 33 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | |
| 34 | import { runtime } from "../../lib/runtime"; | |
| 35 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 36 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 37 | import { SettingsRow } from "./components/SettingsRow"; | |
| 38 | import { SettingsSection } from "./components/SettingsSection"; | |
| 39 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | |
| 40 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 41 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | |
| 42 | ||
| 43 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | |
| 44 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | |
| 45 | ||
| 46 | // Reflects whether the relay actually accepted this device's registration. | |
| 47 | // The notification and Live Activity switches are gated on this so they can | |
| 48 | // never read as enabled when the device cannot receive anything (e.g. the | |
| 49 | // registration request timed out). | |
| 50 | function useDeviceRegistered(): boolean { | |
| 51 | const status = useSyncExternalStore( | |
| 52 | subscribeAgentAwarenessRegistrationStatus, | |
| 53 | getAgentAwarenessRegistrationStatus, | |
| 54 | () => "unknown" as const, | |
| 55 | ); | |
| 56 | return status === "registered"; | |
| 57 | } | |
| 58 | ||
| 59 | export function SettingsNotificationsRouteScreen() { | |
| 60 | if (!hasCloudPublicConfig()) { | |
| 61 | return ( | |
| 62 | <SettingsScreen title="Notifications"> | |
| 63 | <ScrollView | |
| 64 | contentInsetAdjustmentBehavior="automatic" | |
| 65 | contentContainerClassName="px-5 pt-4" | |
| 66 | > | |
| 67 | <Text className="text-base text-foreground-muted"> | |
| 68 | Notifications require T3 Connect in this app build. | |
| 69 | </Text> | |
| 70 | </ScrollView> | |
| 71 | </SettingsScreen> | |
| 72 | ); | |
| 73 | } | |
| 74 | ||
| 75 | return <ConfiguredSettingsNotificationsRouteScreen />; | |
| 76 | } | |
| 77 | ||
| 78 | function ConfiguredSettingsNotificationsRouteScreen() { | |
| 79 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | |
| 80 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 81 | const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); | |
| 82 | const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); | |
| 83 | const agentAwarenessSubtitle = | |
| 84 | Platform.OS === "android" && !agentAwarenessPushAvailable | |
| 85 | ? "Install a newer app build to enable notifications" | |
| 86 | : agentAwarenessPlatform.subtitle; | |
| 87 | const insets = useSafeAreaInsets(); | |
| 88 | const navigation = useNavigation(); | |
| 89 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 90 | const { savedConnectionsById } = useSavedRemoteConnections(); | |
| 91 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | |
| 92 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | |
| 93 | const liveActivityWriteInFlight = useRef(false); | |
| 94 | const deviceRegistered = useDeviceRegistered(); | |
| 95 | const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) | |
| 96 | ? preferencesResult.value.liveActivitiesEnabled !== false | |
| 97 | : true; | |
| 98 | const canClearLiveActivitiesPreference = | |
| 99 | AsyncResult.isSuccess(preferencesResult) && | |
| 100 | preferencesResult.value.liveActivitiesEnabled !== false; | |
| 101 | ||
| 102 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | |
| 103 | const environmentCount = connections.length; | |
| 104 | ||
| 105 | const refreshNotifications = useCallback(async () => { | |
| 106 | if (Platform.OS !== "ios" && Platform.OS !== "android") { | |
| 107 | setNotificationStatus("unsupported"); | |
| 108 | return; | |
| 109 | } | |
| 110 | const result = await settlePromise(() => Notifications.getPermissionsAsync()); | |
| 111 | if (result._tag === "Failure") { | |
| 112 | reportAtomCommandResult(result, { label: "notification permission refresh" }); | |
| 113 | setNotificationStatus("disabled"); | |
| 114 | return; | |
| 115 | } | |
| 116 | setNotificationStatus(result.value.granted ? "enabled" : "disabled"); | |
| 117 | }, []); | |
| 118 | ||
| 119 | useEffect(() => { | |
| 120 | void refreshNotifications(); | |
| 121 | const subscription = AppState.addEventListener("change", (state) => { | |
| 122 | if (state === "active") void refreshNotifications(); | |
| 123 | }); | |
| 124 | return () => subscription.remove(); | |
| 125 | }, [refreshNotifications]); | |
| 126 | ||
| 127 | useEffect(() => { | |
| 128 | if (!isLoaded) { | |
| 129 | setLiveActivityStatus("checking"); | |
| 130 | return; | |
| 131 | } | |
| 132 | if (!isSignedIn) { | |
| 133 | setLiveActivityStatus("signed-out"); | |
| 134 | return; | |
| 135 | } | |
| 136 | if (!AsyncResult.isSuccess(preferencesResult)) { | |
| 137 | if (AsyncResult.isFailure(preferencesResult)) { | |
| 138 | reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); | |
| 139 | setLiveActivityStatus("enabled"); | |
| 140 | } else { | |
| 141 | setLiveActivityStatus("checking"); | |
| 142 | } | |
| 143 | return; | |
| 144 | } | |
| 145 | setLiveActivityStatus( | |
| 146 | preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", | |
| 147 | ); | |
| 148 | }, [isLoaded, isSignedIn, preferencesResult]); | |
| 149 | ||
| 150 | const requestNotifications = useCallback(async () => { | |
| 151 | const result = await settleAsyncResult(() => | |
| 152 | runtime.runPromiseExit( | |
| 153 | requestAgentNotificationPermission.pipe( | |
| 154 | Effect.tap((permission) => | |
| 155 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | |
| 156 | ), | |
| 157 | ), | |
| 158 | ), | |
| 159 | ); | |
| 160 | if (result._tag === "Failure") { | |
| 161 | if (!isAtomCommandInterrupted(result)) { | |
| 162 | const error = squashAtomCommandFailure(result); | |
| 163 | Alert.alert( | |
| 164 | "Notifications unavailable", | |
| 165 | error instanceof Error ? error.message : "Could not request notification permission.", | |
| 166 | ); | |
| 167 | } | |
| 168 | return; | |
| 169 | } | |
| 170 | if (result.value.type === "granted") { | |
| 171 | setNotificationStatus("enabled"); | |
| 172 | // Permission alone is not enough: the switch stays off until the relay | |
| 173 | // registration succeeds, so tell the user the truth about which happened. | |
| 174 | if (getAgentAwarenessRegistrationStatus() === "registered") { | |
| 175 | Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); | |
| 176 | } else { | |
| 177 | Alert.alert( | |
| 178 | "Couldn't finish enabling notifications", | |
| 179 | "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", | |
| 180 | ); | |
| 181 | } | |
| 182 | return; | |
| 183 | } | |
| 184 | if (result.value.type === "unsupported") { | |
| 185 | setNotificationStatus("unsupported"); | |
| 186 | Alert.alert( | |
| 187 | "Notifications unavailable", | |
| 188 | "Agent notifications are unavailable on this platform.", | |
| 189 | ); | |
| 190 | return; | |
| 191 | } | |
| 192 | setNotificationStatus("disabled"); | |
| 193 | if (result.value.canAskAgain) { | |
| 194 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | |
| 195 | return; | |
| 196 | } | |
| 197 | Alert.alert( | |
| 198 | "Notifications disabled", | |
| 199 | "Notifications were denied for this app. Open Settings to enable them.", | |
| 200 | [ | |
| 201 | { text: "Cancel", style: "cancel" }, | |
| 202 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 203 | ], | |
| 204 | ); | |
| 205 | }, []); | |
| 206 | ||
| 207 | const promptSignIn = useCallback(() => { | |
| 208 | Alert.alert( | |
| 209 | "Sign in to T3 Connect", | |
| 210 | "Live Activity updates require T3 Connect so relay can deliver updates to this device.", | |
| 211 | [ | |
| 212 | { text: "Cancel", style: "cancel" }, | |
| 213 | { | |
| 214 | text: "Continue", | |
| 215 | onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), | |
| 216 | }, | |
| 217 | ], | |
| 218 | ); | |
| 219 | }, [navigation]); | |
| 220 | ||
| 221 | const linkEnvironments = useCallback(async () => { | |
| 222 | if (!isSignedIn) { | |
| 223 | promptSignIn(); | |
| 224 | return; | |
| 225 | } | |
| 226 | ||
| 227 | setLiveActivityStatus("linking"); | |
| 228 | if (Platform.OS === "android") { | |
| 229 | const permission = await settleAsyncResult(() => | |
| 230 | runtime.runPromiseExit(requestAgentNotificationPermission), | |
| 231 | ); | |
| 232 | if (permission._tag === "Failure") { | |
| 233 | setLiveActivityStatus("disabled"); | |
| 234 | const error = squashAtomCommandFailure(permission); | |
| 235 | Alert.alert( | |
| 236 | "Ongoing activity unavailable", | |
| 237 | error instanceof Error ? error.message : "Could not enable agent notifications.", | |
| 238 | ); | |
| 239 | return; | |
| 240 | } | |
| 241 | if (permission.value.type !== "granted") { | |
| 242 | setLiveActivityStatus("disabled"); | |
| 243 | Alert.alert( | |
| 244 | "Notification permission needed", | |
| 245 | "Enable notifications in system Settings to show ongoing agent activity.", | |
| 246 | [ | |
| 247 | { text: "Cancel", style: "cancel" }, | |
| 248 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 249 | ], | |
| 250 | ); | |
| 251 | return; | |
| 252 | } | |
| 253 | setNotificationStatus("enabled"); | |
| 254 | } | |
| 255 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | |
| 256 | if (tokenResult._tag === "Failure") { | |
| 257 | setLiveActivityStatus("disabled"); | |
| 258 | const error = squashAtomCommandFailure(tokenResult); | |
| 259 | Alert.alert( | |
| 260 | Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", | |
| 261 | error instanceof Error ? error.message : "Could not enable agent activity updates.", | |
| 262 | ); | |
| 263 | return; | |
| 264 | } | |
| 265 | if (!tokenResult.value) { | |
| 266 | promptSignIn(); | |
| 267 | setLiveActivityStatus("signed-out"); | |
| 268 | return; | |
| 269 | } | |
| 270 | ||
| 271 | const updateResult = await settleAsyncResult(() => | |
| 272 | runtime.runPromiseExit( | |
| 273 | setLiveActivityUpdatesEnabled({ | |
| 274 | enabled: true, | |
| 275 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 276 | clerkToken: tokenResult.value, | |
| 277 | connections, | |
| 278 | }), | |
| 279 | ), | |
| 280 | ); | |
| 281 | if (updateResult._tag === "Failure") { | |
| 282 | setLiveActivityStatus("disabled"); | |
| 283 | if (!isAtomCommandInterrupted(updateResult)) { | |
| 284 | const error = squashAtomCommandFailure(updateResult); | |
| 285 | Alert.alert( | |
| 286 | Platform.OS === "android" | |
| 287 | ? "Ongoing activity unavailable" | |
| 288 | : "Live Activities unavailable", | |
| 289 | error instanceof Error ? error.message : "Could not enable agent activity updates.", | |
| 290 | ); | |
| 291 | } | |
| 292 | return; | |
| 293 | } | |
| 294 | ||
| 295 | savePreferences({ liveActivitiesEnabled: true }); | |
| 296 | refreshManagedRelayEnvironments(); | |
| 297 | setLiveActivityStatus("enabled"); | |
| 298 | // The environment link can succeed while this device's own registration | |
| 299 | // (the push-to-start token the relay needs) has not — don't claim Live | |
| 300 | // Activities are live until the device is actually registered. | |
| 301 | if (getAgentAwarenessRegistrationStatus() === "registered") { | |
| 302 | Alert.alert( | |
| 303 | Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", | |
| 304 | environmentCount > 0 | |
| 305 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` | |
| 306 | : "Agent activity updates are enabled. Add an environment to start receiving updates.", | |
| 307 | ); | |
| 308 | } else { | |
| 309 | Alert.alert( | |
| 310 | "Couldn't finish enabling activity updates", | |
| 311 | "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", | |
| 312 | ); | |
| 313 | } | |
| 314 | }, [ | |
| 315 | connections, | |
| 316 | environmentCount, | |
| 317 | getToken, | |
| 318 | isSignedIn, | |
| 319 | liveActivitiesPreferenceEnabled, | |
| 320 | promptSignIn, | |
| 321 | savePreferences, | |
| 322 | ]); | |
| 323 | ||
| 324 | const handleDeviceNotificationsChange = useCallback( | |
| 325 | (enabled: boolean) => { | |
| 326 | if (enabled) { | |
| 327 | if (!isSignedIn) { | |
| 328 | promptSignIn(); | |
| 329 | return; | |
| 330 | } | |
| 331 | void requestNotifications(); | |
| 332 | return; | |
| 333 | } | |
| 334 | ||
| 335 | Alert.alert( | |
| 336 | "Disable notifications", | |
| 337 | "Open system Settings to disable notifications for T3 Code.", | |
| 338 | [ | |
| 339 | { text: "Cancel", style: "cancel" }, | |
| 340 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 341 | ], | |
| 342 | ); | |
| 343 | }, | |
| 344 | [isSignedIn, promptSignIn, requestNotifications], | |
| 345 | ); | |
| 346 | ||
| 347 | const handleLiveActivitiesChange = useCallback( | |
| 348 | (enabled: boolean) => { | |
| 349 | if (liveActivityWriteInFlight.current) return; | |
| 350 | if (!enabled) { | |
| 351 | liveActivityWriteInFlight.current = true; | |
| 352 | setLiveActivityStatus("linking"); | |
| 353 | void (async () => { | |
| 354 | try { | |
| 355 | let token: string | null = null; | |
| 356 | if (isSignedIn) { | |
| 357 | const tokenResult = await settlePromise(() => | |
| 358 | getToken(resolveRelayClerkTokenOptions()), | |
| 359 | ); | |
| 360 | if (tokenResult._tag === "Failure") { | |
| 361 | setLiveActivityStatus("enabled"); | |
| 362 | reportAtomCommandResult(tokenResult, { | |
| 363 | label: "live activity disable token lookup", | |
| 364 | }); | |
| 365 | return; | |
| 366 | } | |
| 367 | token = tokenResult.value; | |
| 368 | } | |
| 369 | ||
| 370 | const updateResult = await settleAsyncResult(() => | |
| 371 | runtime.runPromiseExit( | |
| 372 | setLiveActivityUpdatesEnabled({ | |
| 373 | enabled: false, | |
| 374 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 375 | clerkToken: token, | |
| 376 | connections, | |
| 377 | }), | |
| 378 | ), | |
| 379 | ); | |
| 380 | if (updateResult._tag === "Failure") { | |
| 381 | setLiveActivityStatus(isSignedIn ? "enabled" : "signed-out"); | |
| 382 | reportAtomCommandResult(updateResult, { | |
| 383 | label: "live activity disable", | |
| 384 | }); | |
| 385 | return; | |
| 386 | } | |
| 387 | savePreferences({ liveActivitiesEnabled: false }); | |
| 388 | refreshManagedRelayEnvironments(); | |
| 389 | setLiveActivityStatus("disabled"); | |
| 390 | } finally { | |
| 391 | liveActivityWriteInFlight.current = false; | |
| 392 | } | |
| 393 | })(); | |
| 394 | return; | |
| 395 | } | |
| 396 | ||
| 397 | if (!isSignedIn) { | |
| 398 | promptSignIn(); | |
| 399 | return; | |
| 400 | } | |
| 401 | ||
| 402 | liveActivityWriteInFlight.current = true; | |
| 403 | void linkEnvironments().finally(() => { | |
| 404 | liveActivityWriteInFlight.current = false; | |
| 405 | }); | |
| 406 | }, | |
| 407 | [ | |
| 408 | connections, | |
| 409 | getToken, | |
| 410 | isSignedIn, | |
| 411 | linkEnvironments, | |
| 412 | liveActivitiesPreferenceEnabled, | |
| 413 | promptSignIn, | |
| 414 | savePreferences, | |
| 415 | ], | |
| 416 | ); | |
| 417 | ||
| 418 | return ( | |
| 419 | <SettingsScreen title="Notifications"> | |
| 420 | <ScrollView | |
| 421 | contentInsetAdjustmentBehavior="automatic" | |
| 422 | showsVerticalScrollIndicator={false} | |
| 423 | className="flex-1" | |
| 424 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 425 | contentContainerStyle={{ | |
| 426 | paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 427 | }} | |
| 428 | > | |
| 429 | <SettingsSection title="Agent activity"> | |
| 430 | <SettingsSwitchRow | |
| 431 | icon="bell.badge" | |
| 432 | label="Device Notifications" | |
| 433 | disabled={ | |
| 434 | !agentAwarenessPlatform.supported || | |
| 435 | !agentAwarenessPushAvailable || | |
| 436 | notificationStatus === "checking" || | |
| 437 | notificationStatus === "unsupported" | |
| 438 | } | |
| 439 | subtitle={agentAwarenessSubtitle} | |
| 440 | // Only reads as on when this device is actually registered with the | |
| 441 | // relay; otherwise notifications cannot be delivered regardless of | |
| 442 | // the local iOS permission. | |
| 443 | value={ | |
| 444 | agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered | |
| 445 | } | |
| 446 | onValueChange={handleDeviceNotificationsChange} | |
| 447 | /> | |
| 448 | <SettingsSwitchRow | |
| 449 | disabled={ | |
| 450 | !agentAwarenessPlatform.supported || | |
| 451 | !agentAwarenessPushAvailable || | |
| 452 | !isLoaded || | |
| 453 | liveActivityStatus === "checking" || | |
| 454 | liveActivityStatus === "linking" | |
| 455 | } | |
| 456 | icon="bolt.circle" | |
| 457 | label={ | |
| 458 | Platform.OS === "android" | |
| 459 | ? supportsAndroidLiveUpdateSettings() | |
| 460 | ? "Agent Live Updates" | |
| 461 | : "Ongoing Agent Activity" | |
| 462 | : "Live Activity Updates" | |
| 463 | } | |
| 464 | subtitle={agentAwarenessSubtitle} | |
| 465 | // Same gate: a saved preference is meaningless until the device | |
| 466 | // registration the relay needs to push updates has succeeded. | |
| 467 | value={ | |
| 468 | agentAwarenessPushAvailable && | |
| 469 | (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && | |
| 470 | deviceRegistered | |
| 471 | } | |
| 472 | onValueChange={handleLiveActivitiesChange} | |
| 473 | /> | |
| 474 | {liveActivityStatus === "signed-out" && canClearLiveActivitiesPreference ? ( | |
| 475 | <SettingsRow | |
| 476 | icon="bolt.circle" | |
| 477 | label="Turn off Live Activity preference" | |
| 478 | onPress={() => handleLiveActivitiesChange(false)} | |
| 479 | /> | |
| 480 | ) : null} | |
| 481 | {supportsAndroidLiveUpdateSettings() ? ( | |
| 482 | <SettingsRow | |
| 483 | icon="bolt.circle" | |
| 484 | label="Live Update Settings" | |
| 485 | onPress={() => { | |
| 486 | void openAndroidLiveUpdateSettings().catch(() => { | |
| 487 | Alert.alert( | |
| 488 | "Couldn't open Settings", | |
| 489 | "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", | |
| 490 | ); | |
| 491 | }); | |
| 492 | }} | |
| 493 | /> | |
| 494 | ) : null} | |
| 495 | </SettingsSection> | |
| 496 | </ScrollView> | |
| 497 | </SettingsScreen> | |
| 498 | ); | |
| 499 | } |
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx+2-2| ... | ... | @@ -47,7 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 47 | 47 | : null; |
| 48 | 48 | |
| 49 | 49 | return ( |
| 50 | <SettingsScreen title="Project Grouping"> | |
| 50 | <SettingsScreen title="Organization"> | |
| 51 | 51 | <ScrollView |
| 52 | 52 | contentInsetAdjustmentBehavior="automatic" |
| 53 | 53 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -55,7 +55,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 55 | 55 | contentContainerClassName="gap-3 px-5 pt-4" |
| 56 | 56 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 57 | 57 | > |
| 58 | <SettingsSection title="Default grouping"> | |
| 58 | <SettingsSection title="Project grouping"> | |
| 59 | 59 | {GROUPING_OPTIONS.map((option, index) => ( |
| 60 | 60 | <Pressable |
| 61 | 61 | key={option.mode} |
apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx created+174| ... | ... | @@ -0,0 +1,174 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 2 | import { AppText as Text, AppTextInput } from "../../components/AppText"; | |
| 3 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 4 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; | |
| 5 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 6 | import { useState } from "react"; | |
| 7 | import { Platform, Pressable, View } from "react-native"; | |
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 9 | ||
| 10 | import { projectEnvironment } from "../../state/projects"; | |
| 11 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 12 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 13 | import { SettingsSection } from "./components/SettingsSection"; | |
| 14 | import { | |
| 15 | AndroidSettingsEnvironmentFilter, | |
| 16 | SettingsEnvironmentFilterHeader, | |
| 17 | } from "./components/SettingsEnvironmentFilterHeader"; | |
| 18 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; | |
| 19 | ||
| 20 | export function SettingsProjectOverviewRouteScreen() { | |
| 21 | const insets = useSafeAreaInsets(); | |
| 22 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); | |
| 23 | const group = projectGroups.find((entry) => entry.key === selectedProjectKey); | |
| 24 | const selectedEnvironmentIds = new Set(selectedTargets.map((entry) => entry.environmentId)); | |
| 25 | const members = | |
| 26 | group?.members | |
| 27 | .map((entry) => entry.project) | |
| 28 | .filter((project) => selectedEnvironmentIds.has(project.environmentId)) ?? []; | |
| 29 | ||
| 30 | return ( | |
| 31 | <> | |
| 32 | <SettingsEnvironmentFilterHeader /> | |
| 33 | <SettingsScreen title="Project overview" trailing={<AndroidSettingsEnvironmentFilter />}> | |
| 34 | <ScrollView | |
| 35 | contentInsetAdjustmentBehavior="automatic" | |
| 36 | showsVerticalScrollIndicator={false} | |
| 37 | className="flex-1" | |
| 38 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 39 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 40 | > | |
| 41 | {members.length === 0 ? ( | |
| 42 | <Text className="px-2 text-base text-foreground-muted"> | |
| 43 | This project has no checkout on the selected connected environments. Change the filter | |
| 44 | above. | |
| 45 | </Text> | |
| 46 | ) : ( | |
| 47 | <ProjectOverviewContent | |
| 48 | key={`${selectedProjectKey}:${members.map((member) => member.id).join(",")}`} | |
| 49 | members={members} | |
| 50 | environments={selectedTargets} | |
| 51 | /> | |
| 52 | )} | |
| 53 | </ScrollView> | |
| 54 | </SettingsScreen> | |
| 55 | </> | |
| 56 | ); | |
| 57 | } | |
| 58 | ||
| 59 | function ProjectOverviewContent(props: { | |
| 60 | readonly members: readonly EnvironmentProject[]; | |
| 61 | readonly environments: readonly SettingsTarget[]; | |
| 62 | }) { | |
| 63 | const representative = props.members[0]!; | |
| 64 | const displayName = deriveProjectGroupLabel({ representative, members: props.members }); | |
| 65 | const [draftName, setDraftName] = useState<string | null>(null); | |
| 66 | const [isSaving, setIsSaving] = useState(false); | |
| 67 | const updateProject = useAtomCommand(projectEnvironment.update, { | |
| 68 | label: "project name update", | |
| 69 | reportFailure: true, | |
| 70 | }); | |
| 71 | const nextName = (draftName ?? displayName).trim(); | |
| 72 | const canSave = !isSaving && nextName.length > 0 && nextName !== displayName; | |
| 73 | ||
| 74 | const saveName = () => { | |
| 75 | if (!canSave) return; | |
| 76 | setIsSaving(true); | |
| 77 | void (async () => { | |
| 78 | try { | |
| 79 | const results = await Promise.all( | |
| 80 | props.members.map((member) => | |
| 81 | updateProject({ | |
| 82 | environmentId: member.environmentId, | |
| 83 | input: { projectId: member.id, title: nextName }, | |
| 84 | }), | |
| 85 | ), | |
| 86 | ); | |
| 87 | if (results.every((result) => result._tag !== "Failure")) setDraftName(null); | |
| 88 | } finally { | |
| 89 | setIsSaving(false); | |
| 90 | } | |
| 91 | })(); | |
| 92 | }; | |
| 93 | ||
| 94 | return ( | |
| 95 | <> | |
| 96 | <View className="flex-row items-center gap-4 px-2"> | |
| 97 | <ProjectFavicon | |
| 98 | environmentId={representative.environmentId} | |
| 99 | projectTitle={displayName} | |
| 100 | workspaceRoot={representative.workspaceRoot} | |
| 101 | faviconPath={representative.faviconPath} | |
| 102 | size={48} | |
| 103 | /> | |
| 104 | <View className="min-w-0 flex-1"> | |
| 105 | <Text className="text-xl font-t3-semibold text-foreground" numberOfLines={2}> | |
| 106 | {displayName} | |
| 107 | </Text> | |
| 108 | <Text className="text-sm text-foreground-muted"> | |
| 109 | {props.members.length === 1 ? "1 checkout" : `${props.members.length} checkouts`} | |
| 110 | </Text> | |
| 111 | </View> | |
| 112 | </View> | |
| 113 | ||
| 114 | <SettingsSection title="Project"> | |
| 115 | <View className="gap-3 p-4"> | |
| 116 | <Text className="text-sm font-t3-medium text-foreground-muted">Name</Text> | |
| 117 | <View className="flex-row items-center gap-3"> | |
| 118 | <AppTextInput | |
| 119 | accessibilityLabel="Project name" | |
| 120 | className="min-h-11 min-w-0 flex-1 rounded-xl border-continuous bg-card px-3 text-base text-foreground" | |
| 121 | value={draftName ?? displayName} | |
| 122 | onChangeText={setDraftName} | |
| 123 | onSubmitEditing={saveName} | |
| 124 | returnKeyType="done" | |
| 125 | editable={!isSaving} | |
| 126 | /> | |
| 127 | {canSave ? ( | |
| 128 | <Pressable | |
| 129 | accessibilityRole="button" | |
| 130 | accessibilityLabel="Save project name" | |
| 131 | onPress={saveName} | |
| 132 | className="rounded-full bg-subtle-strong px-4 py-2 active:opacity-70" | |
| 133 | > | |
| 134 | <Text className="text-sm font-t3-medium text-foreground">Save</Text> | |
| 135 | </Pressable> | |
| 136 | ) : null} | |
| 137 | </View> | |
| 138 | </View> | |
| 139 | </SettingsSection> | |
| 140 | ||
| 141 | <SettingsSection title="Checkouts"> | |
| 142 | {props.members.map((member, index) => { | |
| 143 | const environment = props.environments.find( | |
| 144 | (entry) => entry.environmentId === member.environmentId, | |
| 145 | ); | |
| 146 | return ( | |
| 147 | <View | |
| 148 | key={`${member.environmentId}:${member.id}`} | |
| 149 | className={index === 0 ? "gap-1 p-4" : "gap-1 border-t border-border-subtle p-4"} | |
| 150 | > | |
| 151 | <Text | |
| 152 | className={ | |
| 153 | Platform.OS === "android" | |
| 154 | ? "text-base text-foreground" | |
| 155 | : "text-lg text-foreground" | |
| 156 | } | |
| 157 | > | |
| 158 | {environment?.label ?? "Environment"} | |
| 159 | </Text> | |
| 160 | {environment?.displayUrl ? ( | |
| 161 | <Text className="text-sm leading-normal text-foreground-muted"> | |
| 162 | {environment.displayUrl} | |
| 163 | </Text> | |
| 164 | ) : null} | |
| 165 | <Text className="text-sm leading-normal text-foreground-muted" selectable> | |
| 166 | {member.workspaceRoot} | |
| 167 | </Text> | |
| 168 | </View> | |
| 169 | ); | |
| 170 | })} | |
| 171 | </SettingsSection> | |
| 172 | </> | |
| 173 | ); | |
| 174 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+103-785| ... | ... | @@ -1,83 +1,23 @@ |
| 1 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; | |
| 2 | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 3 | 2 | import { useAuth, useUser } from "@clerk/expo"; |
| 4 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 5 | import Constants from "expo-constants"; | |
| 6 | import * as Notifications from "expo-notifications"; | |
| 7 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 8 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 9 | import { SymbolView } from "../../components/AppSymbol"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 12 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; | |
| 13 | import { Alert, Linking, Platform, Pressable, View } from "react-native"; | |
| 4 | import { Platform, View } from "react-native"; | |
| 5 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; | |
| 14 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | 7 | |
| 16 | import { | |
| 17 | isAtomCommandInterrupted, | |
| 18 | reportAtomCommandResult, | |
| 19 | settleAsyncResult, | |
| 20 | settlePromise, | |
| 21 | squashAtomCommandFailure, | |
| 22 | } from "@t3tools/client-runtime/state/runtime"; | |
| 23 | import { AppText as Text } from "../../components/AppText"; | |
| 24 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; | |
| 25 | import { | |
| 26 | openAndroidLiveUpdateSettings, | |
| 27 | supportsAndroidLiveUpdateSettings, | |
| 28 | } from "../agent-awareness/androidNotifications"; | |
| 29 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; | |
| 30 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; | |
| 31 | import { | |
| 32 | getAgentAwarenessRegistrationStatus, | |
| 33 | refreshAgentAwarenessRegistration, | |
| 34 | subscribeAgentAwarenessRegistrationStatus, | |
| 35 | } from "../agent-awareness/remoteRegistration"; | |
| 36 | import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; | |
| 37 | import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; | |
| 38 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | |
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | |
| 39 | 9 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 40 | import { runtime } from "../../lib/runtime"; | |
| 41 | import { cn } from "../../lib/cn"; | |
| 42 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 43 | import { serverEnvironment } from "../../state/server"; | |
| 44 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 45 | import { useEnvironments } from "../../state/environments"; | |
| 46 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 47 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; | |
| 48 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 49 | import { | |
| 50 | type AppUpdateCheckState, | |
| 51 | isAppUpdateCheckAvailable, | |
| 52 | registerHiddenUpdateTap, | |
| 53 | runAppUpdateCheck, | |
| 54 | } from "../updates/app-updates"; | |
| 55 | 10 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 56 | 11 | import { SettingsRow } from "./components/SettingsRow"; |
| 57 | 12 | import { SettingsSection } from "./components/SettingsSection"; |
| 58 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | |
| 59 | 13 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 60 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | |
| 61 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; | |
| 62 | ||
| 63 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | |
| 64 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | |
| 65 | ||
| 66 | // Reflects whether the relay actually accepted this device's registration. | |
| 67 | // The notification and Live Activity switches are gated on this so they can | |
| 68 | // never read as enabled when the device cannot receive anything (e.g. the | |
| 69 | // registration request timed out). | |
| 70 | function useDeviceRegistered(): boolean { | |
| 71 | const status = useSyncExternalStore( | |
| 72 | subscribeAgentAwarenessRegistrationStatus, | |
| 73 | getAgentAwarenessRegistrationStatus, | |
| 74 | () => "unknown" as const, | |
| 75 | ); | |
| 76 | return status === "registered"; | |
| 77 | } | |
| 14 | import { | |
| 15 | AndroidSettingsEnvironmentFilter, | |
| 16 | SettingsEnvironmentFilterHeader, | |
| 17 | } from "./components/SettingsEnvironmentFilterHeader"; | |
| 18 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; | |
| 78 | 19 | |
| 79 | 20 | export function SettingsRouteScreen() { |
| 80 | const navigation = useNavigation(); | |
| 81 | 21 | const content = hasCloudPublicConfig() ? ( |
| 82 | 22 | <ConfiguredSettingsRouteScreen /> |
| 83 | 23 | ) : ( |
| ... | ... | @@ -87,27 +27,11 @@ export function SettingsRouteScreen() { |
| 87 | 27 | return ( |
| 88 | 28 | <> |
| 89 | 29 | <WorkspaceSidebarToolbar /> |
| 90 | {Platform.OS !== "android" ? ( | |
| 91 | <NativeStackScreenOptions | |
| 92 | options={{ | |
| 93 | unstable_headerRightItems: | |
| 94 | Platform.OS === "ios" | |
| 95 | ? () => [ | |
| 96 | withNativeGlassHeaderItem({ | |
| 97 | accessibilityLabel: "Close settings", | |
| 98 | icon: { name: "xmark", type: "sfSymbol" } as const, | |
| 99 | identifier: "settings-close", | |
| 100 | label: "", | |
| 101 | onPress: () => navigation.goBack(), | |
| 102 | type: "button", | |
| 103 | }), | |
| 104 | ] | |
| 105 | : undefined, | |
| 106 | }} | |
| 107 | /> | |
| 108 | ) : null} | |
| 30 | <SettingsEnvironmentFilterHeader closeSettings /> | |
| 109 | 31 | {Platform.OS === "android" ? ( |
| 110 | <SettingsScreen title="Settings">{content}</SettingsScreen> | |
| 32 | <SettingsScreen title="Settings" trailing={<AndroidSettingsEnvironmentFilter />}> | |
| 33 | {content} | |
| 34 | </SettingsScreen> | |
| 111 | 35 | ) : ( |
| 112 | 36 | content |
| 113 | 37 | )} |
| ... | ... | @@ -115,10 +39,17 @@ export function SettingsRouteScreen() { |
| 115 | 39 | ); |
| 116 | 40 | } |
| 117 | 41 | |
| 118 | function LocalSettingsRouteScreen() { | |
| 42 | function ConfiguredSettingsRouteScreen() { | |
| 119 | 43 | const insets = useSafeAreaInsets(); |
| 44 | const navigation = useNavigation(); | |
| 45 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 46 | const { user } = useUser(); | |
| 120 | 47 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 121 | const environmentCount = Object.keys(savedConnectionsById).length; | |
| 48 | const accountLabel = !isLoaded | |
| 49 | ? "Checking" | |
| 50 | : !isSignedIn | |
| 51 | ? "Sign in" | |
| 52 | : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); | |
| 122 | 53 | |
| 123 | 54 | return ( |
| 124 | 55 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -126,368 +57,37 @@ function LocalSettingsRouteScreen() { |
| 126 | 57 | contentInsetAdjustmentBehavior="automatic" |
| 127 | 58 | showsVerticalScrollIndicator={false} |
| 128 | 59 | className="flex-1" |
| 129 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 130 | contentContainerStyle={{ | |
| 131 | paddingBottom: Math.max(insets.bottom, 18) + 18, | |
| 132 | }} | |
| 60 | contentContainerClassName="gap-4 px-5 pt-4" | |
| 61 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 133 | 62 | > |
| 134 | <SettingsSection title="Configuration"> | |
| 63 | <SettingsSection title="Connections"> | |
| 64 | <SettingsRow | |
| 65 | icon="person.crop.circle" | |
| 66 | label="T3 Account" | |
| 67 | value={accountLabel} | |
| 68 | disabled={!isLoaded} | |
| 69 | onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })} | |
| 70 | /> | |
| 135 | 71 | <SettingsRow |
| 136 | 72 | icon="desktopcomputer" |
| 137 | 73 | label="Environments" |
| 138 | value={`${environmentCount}`} | |
| 74 | value={`${Object.keys(savedConnectionsById).length}`} | |
| 139 | 75 | valuePosition="trailing" |
| 140 | 76 | target="SettingsEnvironments" |
| 141 | 77 | /> |
| 78 | <SettingsRow icon="bell.badge" label="Notifications" target="SettingsNotifications" /> | |
| 142 | 79 | </SettingsSection> |
| 143 | 80 | |
| 144 | <GeneralSettingsSection /> | |
| 145 | ||
| 146 | <SettingsSection title="Appearance"> | |
| 147 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | |
| 148 | </SettingsSection> | |
| 149 | ||
| 150 | <LegacySettingsSection /> | |
| 151 | ||
| 152 | <ArchivedThreadsSettingsSection /> | |
| 153 | ||
| 154 | <AppSettingsSection /> | |
| 81 | <SettingsIndexSections /> | |
| 155 | 82 | </ScrollView> |
| 156 | 83 | </View> |
| 157 | 84 | ); |
| 158 | 85 | } |
| 159 | 86 | |
| 160 | function ConfiguredSettingsRouteScreen() { | |
| 161 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | |
| 162 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 163 | const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); | |
| 164 | const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); | |
| 165 | const agentAwarenessSubtitle = | |
| 166 | Platform.OS === "android" && !agentAwarenessPushAvailable | |
| 167 | ? "Install a newer app build to enable notifications" | |
| 168 | : agentAwarenessPlatform.subtitle; | |
| 87 | function LocalSettingsRouteScreen() { | |
| 169 | 88 | const insets = useSafeAreaInsets(); |
| 170 | const navigation = useNavigation(); | |
| 171 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 172 | const { user } = useUser(); | |
| 173 | 89 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 174 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | |
| 175 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | |
| 176 | const deviceRegistered = useDeviceRegistered(); | |
| 177 | const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) | |
| 178 | ? preferencesResult.value.liveActivitiesEnabled !== false | |
| 179 | : true; | |
| 180 | ||
| 181 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | |
| 182 | const environmentCount = connections.length; | |
| 183 | const accountLabel = useMemo(() => { | |
| 184 | if (!isLoaded) return "Checking"; | |
| 185 | if (!isSignedIn) return "Sign in"; | |
| 186 | return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; | |
| 187 | }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); | |
| 188 | ||
| 189 | const refreshNotifications = useCallback(async () => { | |
| 190 | if (Platform.OS !== "ios" && Platform.OS !== "android") { | |
| 191 | setNotificationStatus("unsupported"); | |
| 192 | return; | |
| 193 | } | |
| 194 | const result = await settlePromise(() => Notifications.getPermissionsAsync()); | |
| 195 | if (result._tag === "Failure") { | |
| 196 | reportAtomCommandResult(result, { label: "notification permission refresh" }); | |
| 197 | setNotificationStatus("disabled"); | |
| 198 | return; | |
| 199 | } | |
| 200 | setNotificationStatus(result.value.granted ? "enabled" : "disabled"); | |
| 201 | }, []); | |
| 202 | ||
| 203 | useEffect(() => { | |
| 204 | void refreshNotifications(); | |
| 205 | }, [refreshNotifications]); | |
| 206 | ||
| 207 | useEffect(() => { | |
| 208 | if (!isLoaded) { | |
| 209 | setLiveActivityStatus("checking"); | |
| 210 | return; | |
| 211 | } | |
| 212 | if (!isSignedIn) { | |
| 213 | setLiveActivityStatus("signed-out"); | |
| 214 | return; | |
| 215 | } | |
| 216 | if (!AsyncResult.isSuccess(preferencesResult)) { | |
| 217 | if (AsyncResult.isFailure(preferencesResult)) { | |
| 218 | reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); | |
| 219 | setLiveActivityStatus("enabled"); | |
| 220 | } else { | |
| 221 | setLiveActivityStatus("checking"); | |
| 222 | } | |
| 223 | return; | |
| 224 | } | |
| 225 | setLiveActivityStatus( | |
| 226 | preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", | |
| 227 | ); | |
| 228 | }, [isLoaded, isSignedIn, preferencesResult]); | |
| 229 | ||
| 230 | const requestNotifications = useCallback(async () => { | |
| 231 | const result = await settleAsyncResult(() => | |
| 232 | runtime.runPromiseExit( | |
| 233 | requestAgentNotificationPermission.pipe( | |
| 234 | Effect.tap((permission) => | |
| 235 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | |
| 236 | ), | |
| 237 | ), | |
| 238 | ), | |
| 239 | ); | |
| 240 | if (result._tag === "Failure") { | |
| 241 | if (!isAtomCommandInterrupted(result)) { | |
| 242 | const error = squashAtomCommandFailure(result); | |
| 243 | Alert.alert( | |
| 244 | "Notifications unavailable", | |
| 245 | error instanceof Error ? error.message : "Could not request notification permission.", | |
| 246 | ); | |
| 247 | } | |
| 248 | return; | |
| 249 | } | |
| 250 | if (result.value.type === "granted") { | |
| 251 | setNotificationStatus("enabled"); | |
| 252 | // Permission alone is not enough: the switch stays off until the relay | |
| 253 | // registration succeeds, so tell the user the truth about which happened. | |
| 254 | if (getAgentAwarenessRegistrationStatus() === "registered") { | |
| 255 | Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); | |
| 256 | } else { | |
| 257 | Alert.alert( | |
| 258 | "Couldn't finish enabling notifications", | |
| 259 | "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", | |
| 260 | ); | |
| 261 | } | |
| 262 | return; | |
| 263 | } | |
| 264 | if (result.value.type === "unsupported") { | |
| 265 | setNotificationStatus("unsupported"); | |
| 266 | Alert.alert( | |
| 267 | "Notifications unavailable", | |
| 268 | "Agent notifications are unavailable on this platform.", | |
| 269 | ); | |
| 270 | return; | |
| 271 | } | |
| 272 | setNotificationStatus("disabled"); | |
| 273 | if (result.value.canAskAgain) { | |
| 274 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | |
| 275 | return; | |
| 276 | } | |
| 277 | Alert.alert( | |
| 278 | "Notifications disabled", | |
| 279 | "Notifications were denied for this app. Open Settings to enable them.", | |
| 280 | [ | |
| 281 | { text: "Cancel", style: "cancel" }, | |
| 282 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 283 | ], | |
| 284 | ); | |
| 285 | }, []); | |
| 286 | ||
| 287 | const promptSignIn = useCallback(() => { | |
| 288 | Alert.alert( | |
| 289 | "Sign in to T3 Connect", | |
| 290 | "Live Activity updates require T3 Connect so relay can deliver updates to this device.", | |
| 291 | [ | |
| 292 | { text: "Cancel", style: "cancel" }, | |
| 293 | { | |
| 294 | text: "Continue", | |
| 295 | onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), | |
| 296 | }, | |
| 297 | ], | |
| 298 | ); | |
| 299 | }, [navigation]); | |
| 300 | ||
| 301 | const linkEnvironments = useCallback(async () => { | |
| 302 | if (!isSignedIn) { | |
| 303 | promptSignIn(); | |
| 304 | return; | |
| 305 | } | |
| 306 | ||
| 307 | setLiveActivityStatus("linking"); | |
| 308 | if (Platform.OS === "android") { | |
| 309 | const permission = await settleAsyncResult(() => | |
| 310 | runtime.runPromiseExit(requestAgentNotificationPermission), | |
| 311 | ); | |
| 312 | if (permission._tag === "Failure") { | |
| 313 | setLiveActivityStatus("disabled"); | |
| 314 | const error = squashAtomCommandFailure(permission); | |
| 315 | Alert.alert( | |
| 316 | "Ongoing activity unavailable", | |
| 317 | error instanceof Error ? error.message : "Could not enable agent notifications.", | |
| 318 | ); | |
| 319 | return; | |
| 320 | } | |
| 321 | if (permission.value.type !== "granted") { | |
| 322 | setLiveActivityStatus("disabled"); | |
| 323 | Alert.alert( | |
| 324 | "Notification permission needed", | |
| 325 | "Enable notifications in system Settings to show ongoing agent activity.", | |
| 326 | [ | |
| 327 | { text: "Cancel", style: "cancel" }, | |
| 328 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 329 | ], | |
| 330 | ); | |
| 331 | return; | |
| 332 | } | |
| 333 | setNotificationStatus("enabled"); | |
| 334 | } | |
| 335 | const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); | |
| 336 | if (tokenResult._tag === "Failure") { | |
| 337 | setLiveActivityStatus("disabled"); | |
| 338 | const error = squashAtomCommandFailure(tokenResult); | |
| 339 | Alert.alert( | |
| 340 | Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", | |
| 341 | error instanceof Error ? error.message : "Could not enable agent activity updates.", | |
| 342 | ); | |
| 343 | return; | |
| 344 | } | |
| 345 | if (!tokenResult.value) { | |
| 346 | promptSignIn(); | |
| 347 | setLiveActivityStatus("signed-out"); | |
| 348 | return; | |
| 349 | } | |
| 350 | ||
| 351 | const updateResult = await settleAsyncResult(() => | |
| 352 | runtime.runPromiseExit( | |
| 353 | setLiveActivityUpdatesEnabled({ | |
| 354 | enabled: true, | |
| 355 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 356 | clerkToken: tokenResult.value, | |
| 357 | connections, | |
| 358 | }), | |
| 359 | ), | |
| 360 | ); | |
| 361 | if (updateResult._tag === "Failure") { | |
| 362 | setLiveActivityStatus("disabled"); | |
| 363 | if (!isAtomCommandInterrupted(updateResult)) { | |
| 364 | const error = squashAtomCommandFailure(updateResult); | |
| 365 | Alert.alert( | |
| 366 | Platform.OS === "android" | |
| 367 | ? "Ongoing activity unavailable" | |
| 368 | : "Live Activities unavailable", | |
| 369 | error instanceof Error ? error.message : "Could not enable agent activity updates.", | |
| 370 | ); | |
| 371 | } | |
| 372 | return; | |
| 373 | } | |
| 374 | ||
| 375 | savePreferences({ liveActivitiesEnabled: true }); | |
| 376 | refreshManagedRelayEnvironments(); | |
| 377 | setLiveActivityStatus("enabled"); | |
| 378 | // The environment link can succeed while this device's own registration | |
| 379 | // (the push-to-start token the relay needs) has not — don't claim Live | |
| 380 | // Activities are live until the device is actually registered. | |
| 381 | if (getAgentAwarenessRegistrationStatus() === "registered") { | |
| 382 | Alert.alert( | |
| 383 | Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", | |
| 384 | environmentCount > 0 | |
| 385 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` | |
| 386 | : "Agent activity updates are enabled. Add an environment to start receiving updates.", | |
| 387 | ); | |
| 388 | } else { | |
| 389 | Alert.alert( | |
| 390 | "Couldn't finish enabling activity updates", | |
| 391 | "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", | |
| 392 | ); | |
| 393 | } | |
| 394 | }, [ | |
| 395 | connections, | |
| 396 | environmentCount, | |
| 397 | getToken, | |
| 398 | isSignedIn, | |
| 399 | liveActivitiesPreferenceEnabled, | |
| 400 | promptSignIn, | |
| 401 | savePreferences, | |
| 402 | ]); | |
| 403 | ||
| 404 | const handleDeviceNotificationsChange = useCallback( | |
| 405 | (enabled: boolean) => { | |
| 406 | if (enabled) { | |
| 407 | if (!isSignedIn) { | |
| 408 | promptSignIn(); | |
| 409 | return; | |
| 410 | } | |
| 411 | void requestNotifications(); | |
| 412 | return; | |
| 413 | } | |
| 414 | ||
| 415 | Alert.alert( | |
| 416 | "Disable notifications", | |
| 417 | "Open system Settings to disable notifications for T3 Code.", | |
| 418 | [ | |
| 419 | { text: "Cancel", style: "cancel" }, | |
| 420 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | |
| 421 | ], | |
| 422 | ); | |
| 423 | }, | |
| 424 | [isSignedIn, promptSignIn, requestNotifications], | |
| 425 | ); | |
| 426 | ||
| 427 | const handleLiveActivitiesChange = useCallback( | |
| 428 | (enabled: boolean) => { | |
| 429 | if (!enabled) { | |
| 430 | setLiveActivityStatus("disabled"); | |
| 431 | void (async () => { | |
| 432 | let token: string | null = null; | |
| 433 | if (isSignedIn) { | |
| 434 | const tokenResult = await settlePromise(() => | |
| 435 | getToken(resolveRelayClerkTokenOptions()), | |
| 436 | ); | |
| 437 | if (tokenResult._tag === "Failure") { | |
| 438 | reportAtomCommandResult(tokenResult, { | |
| 439 | label: "live activity disable token lookup", | |
| 440 | }); | |
| 441 | return; | |
| 442 | } | |
| 443 | token = tokenResult.value; | |
| 444 | } | |
| 445 | ||
| 446 | const updateResult = await settleAsyncResult(() => | |
| 447 | runtime.runPromiseExit( | |
| 448 | setLiveActivityUpdatesEnabled({ | |
| 449 | enabled: false, | |
| 450 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 451 | clerkToken: token, | |
| 452 | connections, | |
| 453 | }), | |
| 454 | ), | |
| 455 | ); | |
| 456 | if (updateResult._tag === "Failure") { | |
| 457 | setLiveActivityStatus("enabled"); | |
| 458 | reportAtomCommandResult(updateResult, { | |
| 459 | label: "live activity disable", | |
| 460 | }); | |
| 461 | return; | |
| 462 | } | |
| 463 | savePreferences({ liveActivitiesEnabled: false }); | |
| 464 | refreshManagedRelayEnvironments(); | |
| 465 | })(); | |
| 466 | return; | |
| 467 | } | |
| 468 | ||
| 469 | if (!isSignedIn) { | |
| 470 | promptSignIn(); | |
| 471 | return; | |
| 472 | } | |
| 473 | ||
| 474 | void linkEnvironments(); | |
| 475 | }, | |
| 476 | [ | |
| 477 | connections, | |
| 478 | getToken, | |
| 479 | isSignedIn, | |
| 480 | linkEnvironments, | |
| 481 | liveActivitiesPreferenceEnabled, | |
| 482 | promptSignIn, | |
| 483 | savePreferences, | |
| 484 | ], | |
| 485 | ); | |
| 486 | ||
| 487 | const openAccount = useCallback(() => { | |
| 488 | if (!isLoaded) return; | |
| 489 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 490 | }, [isLoaded, navigation]); | |
| 90 | const environmentCount = Object.keys(savedConnectionsById).length; | |
| 491 | 91 | |
| 492 | 92 | return ( |
| 493 | 93 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | ... | @@ -495,26 +95,12 @@ function ConfiguredSettingsRouteScreen() { |
| 495 | 95 | contentInsetAdjustmentBehavior="automatic" |
| 496 | 96 | showsVerticalScrollIndicator={false} |
| 497 | 97 | className="flex-1" |
| 498 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 98 | contentContainerClassName="gap-4 px-5 pt-4" | |
| 499 | 99 | contentContainerStyle={{ |
| 500 | 100 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 501 | 101 | }} |
| 502 | 102 | > |
| 503 | <View className="gap-3"> | |
| 504 | <SettingsSection title="Account"> | |
| 505 | <SettingsRow | |
| 506 | icon="person.crop.circle" | |
| 507 | label="T3 Account" | |
| 508 | value={accountLabel} | |
| 509 | onPress={openAccount} | |
| 510 | /> | |
| 511 | </SettingsSection> | |
| 512 | <Text className="px-2 text-sm text-foreground-muted"> | |
| 513 | T3 Code works locally without signing in. Cloud features are optional. | |
| 514 | </Text> | |
| 515 | </View> | |
| 516 | ||
| 517 | <SettingsSection title="Configuration"> | |
| 103 | <SettingsSection title="Connections"> | |
| 518 | 104 | <SettingsRow |
| 519 | 105 | icon="desktopcomputer" |
| 520 | 106 | label="Environments" |
| ... | ... | @@ -522,353 +108,85 @@ function ConfiguredSettingsRouteScreen() { |
| 522 | 108 | valuePosition="trailing" |
| 523 | 109 | target="SettingsEnvironments" |
| 524 | 110 | /> |
| 525 | <SettingsSwitchRow | |
| 526 | icon="bell.badge" | |
| 527 | label="Device Notifications" | |
| 528 | disabled={ | |
| 529 | !agentAwarenessPlatform.supported || | |
| 530 | !agentAwarenessPushAvailable || | |
| 531 | notificationStatus === "checking" || | |
| 532 | notificationStatus === "unsupported" | |
| 533 | } | |
| 534 | subtitle={agentAwarenessSubtitle} | |
| 535 | // Only reads as on when this device is actually registered with the | |
| 536 | // relay; otherwise notifications cannot be delivered regardless of | |
| 537 | // the local iOS permission. | |
| 538 | value={ | |
| 539 | agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered | |
| 540 | } | |
| 541 | onValueChange={handleDeviceNotificationsChange} | |
| 542 | /> | |
| 543 | <SettingsSwitchRow | |
| 544 | disabled={ | |
| 545 | !agentAwarenessPlatform.supported || | |
| 546 | !agentAwarenessPushAvailable || | |
| 547 | !isLoaded || | |
| 548 | liveActivityStatus === "checking" || | |
| 549 | liveActivityStatus === "linking" | |
| 550 | } | |
| 551 | icon="bolt.circle" | |
| 552 | label={ | |
| 553 | Platform.OS === "android" | |
| 554 | ? supportsAndroidLiveUpdateSettings() | |
| 555 | ? "Agent Live Updates" | |
| 556 | : "Ongoing Agent Activity" | |
| 557 | : "Live Activity Updates" | |
| 558 | } | |
| 559 | subtitle={agentAwarenessSubtitle} | |
| 560 | // Same gate: a saved preference is meaningless until the device | |
| 561 | // registration the relay needs to push updates has succeeded. | |
| 562 | value={ | |
| 563 | agentAwarenessPushAvailable && | |
| 564 | (liveActivityStatus === "enabled" || liveActivityStatus === "linking") && | |
| 565 | deviceRegistered | |
| 566 | } | |
| 567 | onValueChange={handleLiveActivitiesChange} | |
| 568 | /> | |
| 569 | {supportsAndroidLiveUpdateSettings() ? ( | |
| 570 | <SettingsRow | |
| 571 | icon="bolt.circle" | |
| 572 | label="Live Update Settings" | |
| 573 | onPress={() => { | |
| 574 | void openAndroidLiveUpdateSettings().catch(() => { | |
| 575 | Alert.alert( | |
| 576 | "Couldn't open Settings", | |
| 577 | "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", | |
| 578 | ); | |
| 579 | }); | |
| 580 | }} | |
| 581 | /> | |
| 582 | ) : null} | |
| 583 | 111 | </SettingsSection> |
| 584 | 112 | |
| 585 | <GeneralSettingsSection /> | |
| 586 | ||
| 587 | <SettingsSection title="Appearance"> | |
| 588 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | |
| 589 | </SettingsSection> | |
| 590 | ||
| 591 | <LegacySettingsSection /> | |
| 592 | ||
| 593 | <ArchivedThreadsSettingsSection /> | |
| 594 | ||
| 595 | <AppSettingsSection /> | |
| 113 | <SettingsIndexSections /> | |
| 596 | 114 | </ScrollView> |
| 597 | 115 | </View> |
| 598 | 116 | ); |
| 599 | 117 | } |
| 600 | 118 | |
| 601 | function GeneralSettingsSection() { | |
| 602 | return ( | |
| 603 | <SettingsSection title="General"> | |
| 604 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> | |
| 605 | {Platform.OS === "ios" ? ( | |
| 606 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> | |
| 607 | ) : null} | |
| 608 | <AutoSettleSettingsRows /> | |
| 609 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | |
| 610 | </SettingsSection> | |
| 611 | ); | |
| 612 | } | |
| 613 | ||
| 614 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | |
| 615 | ||
| 616 | /** | |
| 617 | * Mobile edits auto-settle defaults across connected, capable environments. | |
| 618 | * The first target supplies the displayed values. Applying them leaves each | |
| 619 | * environment's other defaults and overrides intact. | |
| 620 | */ | |
| 621 | function AutoSettleSettingsRows() { | |
| 622 | const { environments } = useEnvironments(); | |
| 623 | const [pendingWrites, setPendingWrites] = useState(0); | |
| 624 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { | |
| 625 | label: "server settings update", | |
| 626 | reportFailure: true, | |
| 627 | }); | |
| 628 | ||
| 629 | const syncTargets = environments.filter(supportsSharedSettingsSync); | |
| 630 | const reference = syncTargets[0] ?? null; | |
| 631 | const referenceSettings = reference?.serverConfig?.settings ?? null; | |
| 632 | ||
| 633 | if (reference === null || referenceSettings === null) { | |
| 634 | return null; | |
| 635 | } | |
| 636 | ||
| 637 | const writeToAll = (patch: Partial<AutoSettleSettings>) => { | |
| 638 | setPendingWrites((count) => count + 1); | |
| 639 | void Promise.allSettled( | |
| 640 | syncTargets.map((environment) => | |
| 641 | updateSettings({ environmentId: environment.environmentId, input: { patch } }), | |
| 642 | ), | |
| 643 | ).finally(() => setPendingWrites((count) => count - 1)); | |
| 644 | }; | |
| 645 | ||
| 646 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( | |
| 647 | { environmentId: reference.environmentId, settings: referenceSettings }, | |
| 648 | syncTargets.map((environment) => ({ | |
| 649 | environmentId: environment.environmentId, | |
| 650 | label: environment.label, | |
| 651 | settings: environment.serverConfig?.settings ?? null, | |
| 652 | })), | |
| 653 | ); | |
| 654 | ||
| 655 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | |
| 656 | ||
| 119 | function SettingsIndexSections() { | |
| 120 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); | |
| 121 | const noServerTargets = selectedTargets.length === 0; | |
| 122 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); | |
| 123 | const scopedProjectMembers = | |
| 124 | selectedProject?.members | |
| 125 | .map((member) => member.project) | |
| 126 | .filter((project) => | |
| 127 | selectedTargets.some((target) => target.environmentId === project.environmentId), | |
| 128 | ) ?? []; | |
| 129 | const projectLabel = | |
| 130 | scopedProjectMembers.length > 0 | |
| 131 | ? deriveProjectGroupLabel({ | |
| 132 | representative: scopedProjectMembers[0]!, | |
| 133 | members: scopedProjectMembers, | |
| 134 | }) | |
| 135 | : (selectedProject?.label ?? "Unavailable project"); | |
| 657 | 136 | return ( |
| 658 | 137 | <> |
| 659 | <SettingsSwitchRow | |
| 660 | icon="arrow.triangle.branch" | |
| 661 | label="Auto-settle merged threads" | |
| 662 | value={referenceSettings.sidebarAutoSettleOnMerge} | |
| 663 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} | |
| 664 | /> | |
| 665 | <SettingsSwitchRow | |
| 666 | icon="clock" | |
| 667 | label="Auto-settle inactive threads" | |
| 668 | value={afterDays !== null} | |
| 669 | onValueChange={(value) => | |
| 670 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) | |
| 671 | } | |
| 672 | /> | |
| 673 | {afterDays !== null ? ( | |
| 674 | <View | |
| 675 | className={cn( | |
| 676 | "flex-row items-center gap-4 px-4", | |
| 677 | Platform.OS === "android" ? "min-h-14 py-3" : "py-4", | |
| 678 | )} | |
| 679 | > | |
| 680 | <View style={{ width: Platform.OS === "android" ? 24 : 22 }} /> | |
| 681 | <Text | |
| 682 | className={cn( | |
| 683 | "flex-1 text-foreground", | |
| 684 | Platform.OS === "android" ? "text-base" : "text-lg", | |
| 685 | )} | |
| 686 | > | |
| 687 | Inactive days | |
| 688 | </Text> | |
| 689 | <AutoSettleDaysField | |
| 690 | value={afterDays} | |
| 691 | onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })} | |
| 692 | /> | |
| 693 | </View> | |
| 694 | ) : null} | |
| 695 | {pendingWrites === 0 && mismatches.length > 0 ? ( | |
| 696 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | |
| 697 | <View className="min-w-0 flex-1"> | |
| 698 | <Text className="text-lg text-foreground">Auto-settle defaults differ</Text> | |
| 699 | <Text className="text-sm text-foreground-muted"> | |
| 700 | {mismatches.map((mismatch) => mismatch.label).join(", ")} | |
| 701 | </Text> | |
| 702 | </View> | |
| 703 | <Pressable | |
| 704 | accessibilityRole="button" | |
| 705 | onPress={() => writeToAll(autoSettlePatch)} | |
| 706 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" | |
| 707 | > | |
| 708 | <Text className="text-base font-t3-medium text-foreground"> | |
| 709 | Apply auto-settle defaults | |
| 710 | </Text> | |
| 711 | </Pressable> | |
| 712 | </View> | |
| 713 | ) : null} | |
| 714 | </> | |
| 715 | ); | |
| 716 | } | |
| 138 | <SettingsSection title="Interface"> | |
| 139 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | |
| 140 | {Platform.OS === "ios" ? ( | |
| 141 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> | |
| 142 | ) : null} | |
| 143 | </SettingsSection> | |
| 717 | 144 | |
| 718 | /** | |
| 719 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is | |
| 720 | * the counterpart of web's Settings → General → Legacy features backed by | |
| 721 | * mobile preferences. | |
| 722 | */ | |
| 723 | function LegacySettingsSection() { | |
| 724 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 725 | const preferences = useAtomValue(mobilePreferencesAtom); | |
| 726 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 727 | const planModeEnabled = | |
| 728 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | |
| 145 | <SettingsSection title="Projects & threads"> | |
| 146 | {selectedProjectKey !== null ? ( | |
| 147 | <SettingsRow | |
| 148 | icon="folder" | |
| 149 | label="Overview" | |
| 150 | value={projectLabel} | |
| 151 | target="SettingsProjectOverview" | |
| 152 | /> | |
| 153 | ) : null} | |
| 154 | <SettingsRow icon="folder" label="Organization" target="SettingsOrganization" /> | |
| 155 | <SettingsRow icon="text.bubble" label="Thread behavior" target="SettingsThreads" /> | |
| 156 | <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" /> | |
| 157 | </SettingsSection> | |
| 729 | 158 | |
| 730 | return ( | |
| 731 | <View className="gap-3"> | |
| 732 | <SettingsSection title="Legacy"> | |
| 733 | <SettingsSwitchRow | |
| 734 | icon="sidebar.left" | |
| 735 | label="Legacy Thread List" | |
| 736 | value={!threadListV2Enabled} | |
| 737 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} | |
| 159 | <SettingsSection title="Server settings"> | |
| 160 | <SettingsRow | |
| 161 | icon="text.bubble" | |
| 162 | label="New threads" | |
| 163 | target="SettingsEnvironmentNewThreads" | |
| 164 | disabled={noServerTargets} | |
| 165 | /> | |
| 166 | <SettingsRow | |
| 167 | icon="arrow.triangle.branch" | |
| 168 | label="Source control" | |
| 169 | target="SettingsEnvironmentSourceControl" | |
| 170 | disabled={noServerTargets} | |
| 738 | 171 | /> |
| 739 | <SettingsSwitchRow | |
| 740 | icon="hammer" | |
| 741 | label="Plan Mode" | |
| 742 | value={planModeEnabled} | |
| 743 | onValueChange={(value) => savePreferences({ planModeEnabled: value })} | |
| 172 | <SettingsRow | |
| 173 | icon="text.alignleft" | |
| 174 | label="Agent behavior" | |
| 175 | target="SettingsEnvironmentAgentBehavior" | |
| 176 | disabled={noServerTargets} | |
| 177 | /> | |
| 178 | <SettingsRow | |
| 179 | icon="arrow.clockwise" | |
| 180 | label="Maintenance" | |
| 181 | target="SettingsEnvironmentMaintenance" | |
| 182 | disabled={noServerTargets} | |
| 744 | 183 | /> |
| 745 | 184 | </SettingsSection> |
| 746 | <Text className="px-2 text-sm text-foreground-muted"> | |
| 747 | Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan | |
| 748 | control; otherwise every task runs in Build mode. | |
| 749 | </Text> | |
| 750 | </View> | |
| 751 | ); | |
| 752 | } | |
| 753 | ||
| 754 | function AppSettingsSection() { | |
| 755 | const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle"); | |
| 756 | const updateInFlight = useRef(false); | |
| 757 | const hiddenUpdateTapCount = useRef(0); | |
| 758 | 185 | |
| 759 | const version = Constants.expoConfig?.version ?? "0.0.0"; | |
| 760 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | |
| 761 | // missing variant never mislabels a production build as development. | |
| 762 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | |
| 763 | const variantLabel = variant === "production" ? "" : capitalize(variant); | |
| 764 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; | |
| 765 | const updateCheckAvailable = isAppUpdateCheckAvailable(); | |
| 766 | const busy = | |
| 767 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; | |
| 768 | ||
| 769 | // "Up to date" is a transient acknowledgement, not a state worth persisting — | |
| 770 | // return the version row to its normal, deliberately quiet state. | |
| 771 | useEffect(() => { | |
| 772 | if (updateState !== "current") return; | |
| 773 | const timer = setTimeout(() => setUpdateState("idle"), 3000); | |
| 774 | return () => clearTimeout(timer); | |
| 775 | }, [updateState]); | |
| 776 | ||
| 777 | const checkForUpdate = useCallback(async () => { | |
| 778 | // `disabled={busy}` only takes effect on the next render, so two taps in the | |
| 779 | // same frame would both get through. The ref closes that window. | |
| 780 | if (updateInFlight.current) return; | |
| 781 | updateInFlight.current = true; | |
| 782 | try { | |
| 783 | // The user asked for this restart by tapping the version row, so it may | |
| 784 | // apply immediately instead of prompting. | |
| 785 | await runAppUpdateCheck({ | |
| 786 | applyMode: "immediate", | |
| 787 | onFailure: (message) => Alert.alert("Update failed", message), | |
| 788 | onStateChange: setUpdateState, | |
| 789 | }); | |
| 790 | } finally { | |
| 791 | updateInFlight.current = false; | |
| 792 | } | |
| 793 | }, []); | |
| 794 | ||
| 795 | const handleVersionPress = useCallback(() => { | |
| 796 | if (!updateCheckAvailable || updateInFlight.current) return; | |
| 797 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); | |
| 798 | hiddenUpdateTapCount.current = tap.nextCount; | |
| 799 | if (tap.shouldCheck) { | |
| 800 | void checkForUpdate(); | |
| 801 | } | |
| 802 | }, [checkForUpdate, updateCheckAvailable]); | |
| 803 | ||
| 804 | const statusLabel = | |
| 805 | updateState === "checking" | |
| 806 | ? "Checking…" | |
| 807 | : updateState === "downloading" | |
| 808 | ? "Downloading…" | |
| 809 | : // "ready" appears only when this check joined an in-flight background-mode | |
| 810 | // check; that download installs at the next backgrounding. | |
| 811 | updateState === "ready" | |
| 812 | ? "Update ready" | |
| 813 | : updateState === "restarting" | |
| 814 | ? "Restarting…" | |
| 815 | : updateState === "current" | |
| 816 | ? "Up to date" | |
| 817 | : null; | |
| 818 | ||
| 819 | const versionRow = ( | |
| 820 | <View className="flex-row items-center gap-4 p-4"> | |
| 821 | <SymbolView | |
| 822 | name="info.circle" | |
| 823 | size={22} | |
| 824 | tintColorClassName="accent-icon" | |
| 825 | type="monochrome" | |
| 826 | weight="regular" | |
| 827 | /> | |
| 828 | <Text className="flex-1 text-lg text-foreground">Version</Text> | |
| 829 | <View className="items-end"> | |
| 830 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> | |
| 831 | {statusLabel ? ( | |
| 832 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> | |
| 833 | ) : null} | |
| 834 | </View> | |
| 835 | </View> | |
| 836 | ); | |
| 837 | ||
| 838 | return ( | |
| 839 | <SettingsSection title="App"> | |
| 840 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | |
| 841 | <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" /> | |
| 842 | <SettingsRow | |
| 843 | icon="doc.on.doc" | |
| 844 | label="Open source licenses" | |
| 845 | target="SettingsOpenSourceLicenses" | |
| 846 | /> | |
| 847 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | |
| 848 | {updateCheckAvailable ? ( | |
| 849 | <Pressable | |
| 850 | accessibilityLabel={`Version ${versionLabel}`} | |
| 851 | accessibilityRole="text" | |
| 852 | disabled={busy} | |
| 853 | onPress={handleVersionPress} | |
| 854 | > | |
| 855 | {versionRow} | |
| 856 | </Pressable> | |
| 857 | ) : ( | |
| 858 | versionRow | |
| 859 | )} | |
| 860 | </SettingsSection> | |
| 861 | ); | |
| 862 | } | |
| 863 | ||
| 864 | function capitalize(value: string): string { | |
| 865 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; | |
| 866 | } | |
| 867 | ||
| 868 | function ArchivedThreadsSettingsSection() { | |
| 869 | return ( | |
| 870 | <SettingsSection title="Threads"> | |
| 871 | <SettingsRow icon="archivebox" label="Archived Threads" target="SettingsArchive" /> | |
| 872 | </SettingsSection> | |
| 186 | <SettingsSection title="App"> | |
| 187 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | |
| 188 | <SettingsRow icon="info.circle" label="About T3 Code" target="SettingsAbout" /> | |
| 189 | </SettingsSection> | |
| 190 | </> | |
| 873 | 191 | ); |
| 874 | 192 | } |
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx created+474| ... | ... | @@ -0,0 +1,474 @@ |
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | |
| 3 | import { AppText as Text } from "../../components/AppText"; | |
| 4 | import { | |
| 5 | type ResponseStreamingMode, | |
| 6 | type ServerSettings, | |
| 7 | type ServerSettingsPatch, | |
| 8 | type ThreadEnvMode, | |
| 9 | PROJECT_SCOPED_SERVER_SETTING_KEYS, | |
| 10 | type ProjectScopedServerSettingKey, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { useRef, useState, type ComponentProps } from "react"; | |
| 13 | import { Alert, Platform, Pressable, View } from "react-native"; | |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 15 | ||
| 16 | import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options"; | |
| 17 | import { serverEnvironment } from "../../state/server"; | |
| 18 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 19 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 20 | import { | |
| 21 | AndroidSettingsEnvironmentFilter, | |
| 22 | SettingsEnvironmentFilterHeader, | |
| 23 | } from "./components/SettingsEnvironmentFilterHeader"; | |
| 24 | import { SettingsSection } from "./components/SettingsSection"; | |
| 25 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | |
| 26 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; | |
| 27 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; | |
| 28 | import { | |
| 29 | planMobileScopedSettingsClear, | |
| 30 | planMobileScopedSettingsPatch, | |
| 31 | resolveMobileSettingsTargets, | |
| 32 | type ScopedMobileSettingsTarget, | |
| 33 | } from "./settings-scoped-server"; | |
| 34 | ||
| 35 | type SettingsPage = "new-threads" | "source-control" | "agent-behavior" | "maintenance"; | |
| 36 | ||
| 37 | const PAGE_TITLES: Record<SettingsPage, string> = { | |
| 38 | "new-threads": "New threads", | |
| 39 | "source-control": "Source control", | |
| 40 | "agent-behavior": "Agent behavior", | |
| 41 | maintenance: "Maintenance", | |
| 42 | }; | |
| 43 | ||
| 44 | const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = { | |
| 45 | "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], | |
| 46 | "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], | |
| 47 | "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], | |
| 48 | maintenance: ["continueThreadsAfterServerUpdate"], | |
| 49 | }; | |
| 50 | ||
| 51 | const WORKSPACE_CHOICES: ReadonlyArray<{ | |
| 52 | readonly mode: ThreadEnvMode; | |
| 53 | readonly label: string; | |
| 54 | readonly description: string; | |
| 55 | }> = [ | |
| 56 | { | |
| 57 | mode: "local", | |
| 58 | label: "Current checkout", | |
| 59 | description: "Start new threads in the existing workspace.", | |
| 60 | }, | |
| 61 | { | |
| 62 | mode: "worktree", | |
| 63 | label: "New worktree", | |
| 64 | description: "Give each new thread a separate checkout.", | |
| 65 | }, | |
| 66 | ]; | |
| 67 | ||
| 68 | const STREAMING_CHOICES: ReadonlyArray<{ | |
| 69 | readonly mode: ResponseStreamingMode; | |
| 70 | readonly label: string; | |
| 71 | readonly description: string; | |
| 72 | }> = [ | |
| 73 | { | |
| 74 | mode: "turn", | |
| 75 | label: "After the turn", | |
| 76 | description: "Show the answer when the agent finishes.", | |
| 77 | }, | |
| 78 | { | |
| 79 | mode: "paragraph", | |
| 80 | label: "Finished paragraphs", | |
| 81 | description: "Show each paragraph or code block as it completes.", | |
| 82 | }, | |
| 83 | { | |
| 84 | mode: "token", | |
| 85 | label: "Token by token (legacy)", | |
| 86 | description: "Repaint for every token; this can be slower.", | |
| 87 | }, | |
| 88 | ]; | |
| 89 | ||
| 90 | export function SettingsEnvironmentNewThreadsRouteScreen() { | |
| 91 | return <ServerSettingsDetail page="new-threads" />; | |
| 92 | } | |
| 93 | ||
| 94 | export function SettingsEnvironmentSourceControlRouteScreen() { | |
| 95 | return <ServerSettingsDetail page="source-control" />; | |
| 96 | } | |
| 97 | ||
| 98 | export function SettingsEnvironmentAgentBehaviorRouteScreen() { | |
| 99 | return <ServerSettingsDetail page="agent-behavior" />; | |
| 100 | } | |
| 101 | ||
| 102 | export function SettingsEnvironmentMaintenanceRouteScreen() { | |
| 103 | return <ServerSettingsDetail page="maintenance" />; | |
| 104 | } | |
| 105 | ||
| 106 | function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | |
| 107 | const insets = useSafeAreaInsets(); | |
| 108 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); | |
| 109 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); | |
| 110 | const projectSelected = selectedProjectKey !== null; | |
| 111 | const targets = resolveMobileSettingsTargets( | |
| 112 | selectedTargets, | |
| 113 | projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, | |
| 114 | ); | |
| 115 | const [pendingWrites, setPendingWrites] = useState(0); | |
| 116 | const writeInFlight = useRef(false); | |
| 117 | const [pendingTargets, setPendingTargets] = useState< | |
| 118 | readonly ScopedMobileSettingsTarget[] | null | |
| 119 | >(null); | |
| 120 | const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets; | |
| 121 | const hasConnectedSelection = targets.length > 0; | |
| 122 | const reference = displayTargets[0] ?? null; | |
| 123 | const uniform = <K extends keyof ServerSettings>(key: K): ServerSettings[K] | null => { | |
| 124 | if (reference === null) return null; | |
| 125 | const value = reference.settings[key]; | |
| 126 | return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; | |
| 127 | }; | |
| 128 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { | |
| 129 | label: "environment settings update", | |
| 130 | reportFailure: true, | |
| 131 | }); | |
| 132 | const write = (patch: ServerSettingsPatch) => { | |
| 133 | if (writeInFlight.current || !hasConnectedSelection) return; | |
| 134 | const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch); | |
| 135 | if (writes.length === 0) return; | |
| 136 | writeInFlight.current = true; | |
| 137 | setPendingTargets(targets); | |
| 138 | setPendingWrites((count) => count + 1); | |
| 139 | void Promise.allSettled( | |
| 140 | writes.map((entry) => | |
| 141 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), | |
| 142 | ), | |
| 143 | ).finally(() => { | |
| 144 | writeInFlight.current = false; | |
| 145 | setPendingTargets(null); | |
| 146 | setPendingWrites((count) => count - 1); | |
| 147 | }); | |
| 148 | }; | |
| 149 | const clearProjectOverrides = () => { | |
| 150 | if (writeInFlight.current) return; | |
| 151 | const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]); | |
| 152 | if (writes.length === 0) return; | |
| 153 | writeInFlight.current = true; | |
| 154 | setPendingTargets(targets); | |
| 155 | setPendingWrites((count) => count + 1); | |
| 156 | void Promise.allSettled( | |
| 157 | writes.map((entry) => | |
| 158 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), | |
| 159 | ), | |
| 160 | ).finally(() => { | |
| 161 | writeInFlight.current = false; | |
| 162 | setPendingTargets(null); | |
| 163 | setPendingWrites((count) => count - 1); | |
| 164 | }); | |
| 165 | }; | |
| 166 | const supportsProjectOverrides = targets.every( | |
| 167 | (target) => | |
| 168 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, | |
| 169 | ); | |
| 170 | const disabled = | |
| 171 | pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides); | |
| 172 | const supportsContinuation = targets.every( | |
| 173 | (target) => | |
| 174 | target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, | |
| 175 | ); | |
| 176 | const disabledFor = (key: string) => | |
| 177 | disabled || | |
| 178 | (projectSelected && | |
| 179 | !PROJECT_SCOPED_SERVER_SETTING_KEYS.includes( | |
| 180 | key as (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number], | |
| 181 | )); | |
| 182 | ||
| 183 | return ( | |
| 184 | <> | |
| 185 | <SettingsEnvironmentFilterHeader /> | |
| 186 | <SettingsScreen | |
| 187 | title={PAGE_TITLES[props.page]} | |
| 188 | trailing={<AndroidSettingsEnvironmentFilter />} | |
| 189 | > | |
| 190 | <ScrollView | |
| 191 | contentInsetAdjustmentBehavior="automatic" | |
| 192 | showsVerticalScrollIndicator={false} | |
| 193 | className="flex-1" | |
| 194 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 195 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 196 | > | |
| 197 | {!hasConnectedSelection || reference === null ? ( | |
| 198 | <Text className="px-2 text-base text-foreground-muted"> | |
| 199 | {projectSelected | |
| 200 | ? "Select a project with a checkout on a connected environment." | |
| 201 | : "Use the filter above to select a connected environment."} | |
| 202 | </Text> | |
| 203 | ) : ( | |
| 204 | <> | |
| 205 | {projectSelected ? ( | |
| 206 | <SettingsProjectOverridesSection | |
| 207 | projectLabel={selectedProject?.label ?? "Unavailable project"} | |
| 208 | hasOverrides={targets.some((target) => | |
| 209 | PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"), | |
| 210 | )} | |
| 211 | supportsOverrides={supportsProjectOverrides} | |
| 212 | pending={pendingWrites > 0} | |
| 213 | onClear={clearProjectOverrides} | |
| 214 | /> | |
| 215 | ) : null} | |
| 216 | {props.page === "new-threads" ? ( | |
| 217 | <> | |
| 218 | <SettingsSection | |
| 219 | title="Default workspace" | |
| 220 | trailing={ | |
| 221 | pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? ( | |
| 222 | <MixedValuesLabel projectSelected={projectSelected} /> | |
| 223 | ) : null | |
| 224 | } | |
| 225 | > | |
| 226 | {WORKSPACE_CHOICES.map((choice, index) => ( | |
| 227 | <ChoiceRow | |
| 228 | key={choice.mode} | |
| 229 | label={choice.label} | |
| 230 | description={choice.description} | |
| 231 | selected={uniform("defaultThreadEnvMode") === choice.mode} | |
| 232 | separated={index > 0} | |
| 233 | disabled={disabledFor("defaultThreadEnvMode")} | |
| 234 | onPress={() => write({ defaultThreadEnvMode: choice.mode })} | |
| 235 | /> | |
| 236 | ))} | |
| 237 | </SettingsSection> | |
| 238 | <SettingsSection | |
| 239 | title="Default permissions" | |
| 240 | trailing={ | |
| 241 | pendingWrites === 0 && uniform("defaultRuntimeMode") === null ? ( | |
| 242 | <MixedValuesLabel projectSelected={projectSelected} /> | |
| 243 | ) : null | |
| 244 | } | |
| 245 | > | |
| 246 | {RUNTIME_MODE_CHOICES.map((choice, index) => ( | |
| 247 | <ChoiceRow | |
| 248 | key={choice.mode} | |
| 249 | label={choice.label} | |
| 250 | description={choice.description} | |
| 251 | selected={uniform("defaultRuntimeMode") === choice.mode} | |
| 252 | separated={index > 0} | |
| 253 | disabled={disabledFor("defaultRuntimeMode")} | |
| 254 | onPress={() => write({ defaultRuntimeMode: choice.mode })} | |
| 255 | /> | |
| 256 | ))} | |
| 257 | </SettingsSection> | |
| 258 | </> | |
| 259 | ) : null} | |
| 260 | ||
| 261 | {props.page === "source-control" ? ( | |
| 262 | <> | |
| 263 | <SettingsSection title="Default branch"> | |
| 264 | <FanoutSwitchRow | |
| 265 | icon="arrow.down.circle" | |
| 266 | label="Automatically pull" | |
| 267 | subtitle="Keep the default branch current when there are no local changes." | |
| 268 | value={uniform("defaultAutoPull")} | |
| 269 | disabled={disabledFor("defaultAutoPull")} | |
| 270 | onValueChange={(value) => write({ defaultAutoPull: value })} | |
| 271 | /> | |
| 272 | </SettingsSection> | |
| 273 | <SettingsSection title="Worktrees"> | |
| 274 | <FanoutSwitchRow | |
| 275 | icon="arrow.triangle.branch" | |
| 276 | label="Start from origin" | |
| 277 | subtitle="Base new worktrees on the remote branch." | |
| 278 | value={uniform("newWorktreesStartFromOrigin")} | |
| 279 | disabled={disabledFor("newWorktreesStartFromOrigin")} | |
| 280 | onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })} | |
| 281 | /> | |
| 282 | </SettingsSection> | |
| 283 | </> | |
| 284 | ) : null} | |
| 285 | ||
| 286 | {props.page === "agent-behavior" ? ( | |
| 287 | <> | |
| 288 | <SettingsSection | |
| 289 | title="Response streaming" | |
| 290 | trailing={ | |
| 291 | pendingWrites === 0 && uniform("responseStreamingMode") === null ? ( | |
| 292 | <MixedValuesLabel projectSelected={projectSelected} /> | |
| 293 | ) : null | |
| 294 | } | |
| 295 | > | |
| 296 | {STREAMING_CHOICES.map((choice, index) => ( | |
| 297 | <ChoiceRow | |
| 298 | key={choice.mode} | |
| 299 | label={choice.label} | |
| 300 | description={choice.description} | |
| 301 | selected={uniform("responseStreamingMode") === choice.mode} | |
| 302 | separated={index > 0} | |
| 303 | disabled={disabledFor("responseStreamingMode")} | |
| 304 | onPress={() => { | |
| 305 | if (choice.mode !== "token") { | |
| 306 | write({ responseStreamingMode: choice.mode }); | |
| 307 | return; | |
| 308 | } | |
| 309 | Alert.alert( | |
| 310 | "Use legacy token streaming?", | |
| 311 | "Repainting every token can make the app slower.", | |
| 312 | [ | |
| 313 | { text: "Cancel", style: "cancel" }, | |
| 314 | { | |
| 315 | text: "Use token streaming", | |
| 316 | onPress: () => write({ responseStreamingMode: "token" }), | |
| 317 | }, | |
| 318 | ], | |
| 319 | ); | |
| 320 | }} | |
| 321 | /> | |
| 322 | ))} | |
| 323 | </SettingsSection> | |
| 324 | <SettingsSection title="Preview browser"> | |
| 325 | <FanoutSwitchRow | |
| 326 | icon="globe" | |
| 327 | label="Agent browser access" | |
| 328 | subtitle="Allow agents to use the in-app preview browser." | |
| 329 | value={uniform("enableAgentBrowserAccess")} | |
| 330 | disabled={disabledFor("enableAgentBrowserAccess")} | |
| 331 | onValueChange={(value) => write({ enableAgentBrowserAccess: value })} | |
| 332 | /> | |
| 333 | </SettingsSection> | |
| 334 | </> | |
| 335 | ) : null} | |
| 336 | ||
| 337 | {props.page === "maintenance" ? ( | |
| 338 | <SettingsSection title="Updates"> | |
| 339 | <FanoutSwitchRow | |
| 340 | icon="arrow.clockwise" | |
| 341 | label="Check provider updates" | |
| 342 | subtitle={ | |
| 343 | projectSelected | |
| 344 | ? "Environment-wide setting. Select All projects to change it." | |
| 345 | : "Check installed provider CLIs for newer versions." | |
| 346 | } | |
| 347 | value={uniform("enableProviderUpdateChecks")} | |
| 348 | disabled={disabledFor("enableProviderUpdateChecks")} | |
| 349 | onValueChange={(value) => write({ enableProviderUpdateChecks: value })} | |
| 350 | /> | |
| 351 | <View className="border-t border-border-subtle"> | |
| 352 | <FanoutSwitchRow | |
| 353 | icon="arrow.uturn.forward" | |
| 354 | label="Continue after restart" | |
| 355 | subtitle={ | |
| 356 | supportsContinuation | |
| 357 | ? "Resume interrupted threads after an update or restart." | |
| 358 | : "Update older servers to control restart continuation." | |
| 359 | } | |
| 360 | value={uniform("continueThreadsAfterServerUpdate")} | |
| 361 | disabled={ | |
| 362 | disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation | |
| 363 | } | |
| 364 | onValueChange={(value) => write({ continueThreadsAfterServerUpdate: value })} | |
| 365 | /> | |
| 366 | </View> | |
| 367 | </SettingsSection> | |
| 368 | ) : null} | |
| 369 | </> | |
| 370 | )} | |
| 371 | </ScrollView> | |
| 372 | </SettingsScreen> | |
| 373 | </> | |
| 374 | ); | |
| 375 | } | |
| 376 | ||
| 377 | function ChoiceRow(props: { | |
| 378 | readonly label: string; | |
| 379 | readonly description: string; | |
| 380 | readonly selected: boolean; | |
| 381 | readonly separated: boolean; | |
| 382 | readonly disabled: boolean; | |
| 383 | readonly onPress: () => void; | |
| 384 | }) { | |
| 385 | return ( | |
| 386 | <Pressable | |
| 387 | accessibilityRole="radio" | |
| 388 | accessibilityState={{ checked: props.selected, disabled: props.disabled }} | |
| 389 | className={ | |
| 390 | props.separated | |
| 391 | ? "flex-row items-center gap-4 border-t border-border-subtle p-4 active:opacity-70" | |
| 392 | : "flex-row items-center gap-4 p-4 active:opacity-70" | |
| 393 | } | |
| 394 | disabled={props.disabled} | |
| 395 | onPress={props.onPress} | |
| 396 | > | |
| 397 | <View className="min-w-0 flex-1 gap-1"> | |
| 398 | <Text | |
| 399 | className={ | |
| 400 | Platform.OS === "android" ? "text-base text-foreground" : "text-lg text-foreground" | |
| 401 | } | |
| 402 | > | |
| 403 | {props.label} | |
| 404 | </Text> | |
| 405 | <Text className="text-sm leading-normal text-foreground-muted">{props.description}</Text> | |
| 406 | </View> | |
| 407 | {props.selected ? ( | |
| 408 | <SymbolView | |
| 409 | name="checkmark" | |
| 410 | size={18} | |
| 411 | tintColorClassName="accent-icon" | |
| 412 | type="monochrome" | |
| 413 | weight="semibold" | |
| 414 | /> | |
| 415 | ) : null} | |
| 416 | </Pressable> | |
| 417 | ); | |
| 418 | } | |
| 419 | ||
| 420 | function MixedValuesLabel(props: { readonly projectSelected: boolean }) { | |
| 421 | return ( | |
| 422 | <Text | |
| 423 | accessibilityLabel={ | |
| 424 | props.projectSelected | |
| 425 | ? "Selected project checkouts use different values" | |
| 426 | : "Selected environments use different values" | |
| 427 | } | |
| 428 | className="px-2 text-sm text-foreground-muted android:px-4" | |
| 429 | > | |
| 430 | Mixed | |
| 431 | </Text> | |
| 432 | ); | |
| 433 | } | |
| 434 | ||
| 435 | function FanoutSwitchRow(props: { | |
| 436 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | |
| 437 | readonly label: string; | |
| 438 | readonly subtitle: string; | |
| 439 | readonly value: boolean | null; | |
| 440 | readonly disabled: boolean; | |
| 441 | readonly onValueChange: (value: boolean) => void; | |
| 442 | }) { | |
| 443 | if (props.value !== null) { | |
| 444 | return ( | |
| 445 | <SettingsSwitchRow | |
| 446 | icon={props.icon} | |
| 447 | label={props.label} | |
| 448 | subtitle={props.subtitle} | |
| 449 | value={props.value} | |
| 450 | disabled={props.disabled} | |
| 451 | onValueChange={props.onValueChange} | |
| 452 | /> | |
| 453 | ); | |
| 454 | } | |
| 455 | ||
| 456 | return ( | |
| 457 | <View className="flex-row items-center gap-4 p-4"> | |
| 458 | <SymbolView name={props.icon} size={22} tintColorClassName="accent-icon" /> | |
| 459 | <View className="min-w-0 flex-1 gap-1"> | |
| 460 | <Text className="text-lg text-foreground">{props.label}</Text> | |
| 461 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> | |
| 462 | </View> | |
| 463 | <Pressable | |
| 464 | accessibilityLabel={`Set ${props.label} on for selected environments`} | |
| 465 | accessibilityRole="button" | |
| 466 | disabled={props.disabled} | |
| 467 | className="rounded-full bg-subtle px-3 py-2 active:opacity-70 disabled:opacity-40" | |
| 468 | onPress={() => props.onValueChange(true)} | |
| 469 | > | |
| 470 | <Text className="text-sm font-t3-medium text-foreground">Mixed · Set on</Text> | |
| 471 | </Pressable> | |
| 472 | </View> | |
| 473 | ); | |
| 474 | } |
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; | |
| 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | |
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | import { useRef, useState } from "react"; | |
| 6 | import { Platform, Pressable, View } from "react-native"; | |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 8 | ||
| 9 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 10 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; | |
| 11 | import { AppText as Text } from "../../components/AppText"; | |
| 12 | import { cn } from "../../lib/cn"; | |
| 13 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 14 | import { serverEnvironment } from "../../state/server"; | |
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 16 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 17 | import { SettingsSection } from "./components/SettingsSection"; | |
| 18 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; | |
| 19 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | |
| 20 | import { SettingsScreen } from "./components/SettingsScreen"; | |
| 21 | import { | |
| 22 | AndroidSettingsEnvironmentFilter, | |
| 23 | SettingsEnvironmentFilterHeader, | |
| 24 | } from "./components/SettingsEnvironmentFilterHeader"; | |
| 25 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; | |
| 26 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; | |
| 27 | import { | |
| 28 | planMobileScopedSettingsClear, | |
| 29 | planMobileScopedSettingsPatch, | |
| 30 | resolveMobileSettingsTargets, | |
| 31 | type ScopedMobileSettingsTarget, | |
| 32 | } from "./settings-scoped-server"; | |
| 33 | ||
| 34 | export function SettingsThreadsRouteScreen() { | |
| 35 | const insets = useSafeAreaInsets(); | |
| 36 | ||
| 37 | return ( | |
| 38 | <> | |
| 39 | <SettingsEnvironmentFilterHeader /> | |
| 40 | <SettingsScreen title="Thread behavior" trailing={<AndroidSettingsEnvironmentFilter />}> | |
| 41 | <ScrollView | |
| 42 | contentInsetAdjustmentBehavior="automatic" | |
| 43 | showsVerticalScrollIndicator={false} | |
| 44 | className="flex-1" | |
| 45 | contentContainerClassName="gap-6 px-5 pt-4" | |
| 46 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | |
| 47 | > | |
| 48 | <AutoSettleSettingsRows /> | |
| 49 | <LegacySettingsSection /> | |
| 50 | </ScrollView> | |
| 51 | </SettingsScreen> | |
| 52 | </> | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | |
| 57 | ||
| 58 | /** | |
| 59 | * Mobile edits auto-settle defaults across selected capable targets. | |
| 60 | */ | |
| 61 | function AutoSettleSettingsRows() { | |
| 62 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); | |
| 63 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); | |
| 64 | const projectSelected = selectedProjectKey !== null; | |
| 65 | const [pendingWrites, setPendingWrites] = useState(0); | |
| 66 | const writeInFlight = useRef(false); | |
| 67 | const [pendingTargets, setPendingTargets] = useState< | |
| 68 | readonly ScopedMobileSettingsTarget[] | null | |
| 69 | >(null); | |
| 70 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { | |
| 71 | label: "server settings update", | |
| 72 | reportFailure: true, | |
| 73 | }); | |
| 74 | ||
| 75 | const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync); | |
| 76 | const syncTargets = resolveMobileSettingsTargets( | |
| 77 | syncEnvironments, | |
| 78 | projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, | |
| 79 | ); | |
| 80 | const displayTargets = | |
| 81 | pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; | |
| 82 | const reference = displayTargets[0] ?? null; | |
| 83 | const referenceSettings = reference?.settings ?? null; | |
| 84 | ||
| 85 | if (reference === null || referenceSettings === null) { | |
| 86 | return null; | |
| 87 | } | |
| 88 | ||
| 89 | const writeToAll = (patch: Partial<AutoSettleSettings>) => { | |
| 90 | if (writeInFlight.current) return; | |
| 91 | const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); | |
| 92 | if (writes.length === 0) return; | |
| 93 | writeInFlight.current = true; | |
| 94 | setPendingTargets(syncTargets); | |
| 95 | setPendingWrites((count) => count + 1); | |
| 96 | void Promise.allSettled( | |
| 97 | writes.map((entry) => | |
| 98 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), | |
| 99 | ), | |
| 100 | ).finally(() => { | |
| 101 | writeInFlight.current = false; | |
| 102 | setPendingTargets(null); | |
| 103 | setPendingWrites((count) => count - 1); | |
| 104 | }); | |
| 105 | }; | |
| 106 | ||
| 107 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( | |
| 108 | { | |
| 109 | environmentId: reference.environment.environmentId, | |
| 110 | projectId: reference.projectId, | |
| 111 | settings: referenceSettings, | |
| 112 | }, | |
| 113 | displayTargets.map((target) => ({ | |
| 114 | environmentId: target.environment.environmentId, | |
| 115 | projectId: target.projectId, | |
| 116 | label: target.environment.label, | |
| 117 | settings: target.settings, | |
| 118 | })), | |
| 119 | ); | |
| 120 | ||
| 121 | const supportsProjectOverrides = syncTargets.every( | |
| 122 | (target) => | |
| 123 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, | |
| 124 | ); | |
| 125 | const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); | |
| 126 | const hasProjectOverrides = | |
| 127 | projectSelected && | |
| 128 | syncTargets.some( | |
| 129 | (target) => | |
| 130 | target.sources.sidebarAutoSettleOnMerge === "project" || | |
| 131 | target.sources.sidebarAutoSettleAfterDays === "project", | |
| 132 | ); | |
| 133 | const clearProjectOverrides = () => { | |
| 134 | if (writeInFlight.current) return; | |
| 135 | const writes = planMobileScopedSettingsClear(syncTargets, [ | |
| 136 | "sidebarAutoSettleOnMerge", | |
| 137 | "sidebarAutoSettleAfterDays", | |
| 138 | ]); | |
| 139 | if (writes.length === 0) return; | |
| 140 | writeInFlight.current = true; | |
| 141 | setPendingTargets(syncTargets); | |
| 142 | setPendingWrites((count) => count + 1); | |
| 143 | void Promise.allSettled( | |
| 144 | writes.map((entry) => | |
| 145 | updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), | |
| 146 | ), | |
| 147 | ).finally(() => { | |
| 148 | writeInFlight.current = false; | |
| 149 | setPendingTargets(null); | |
| 150 | setPendingWrites((count) => count - 1); | |
| 151 | }); | |
| 152 | }; | |
| 153 | ||
| 154 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | |
| 155 | ||
| 156 | return ( | |
| 157 | <View className="gap-6"> | |
| 158 | {projectSelected ? ( | |
| 159 | <SettingsProjectOverridesSection | |
| 160 | projectLabel={selectedProject?.label ?? "Unavailable project"} | |
| 161 | hasOverrides={hasProjectOverrides} | |
| 162 | supportsOverrides={supportsProjectOverrides} | |
| 163 | pending={pendingWrites > 0} | |
| 164 | onClear={clearProjectOverrides} | |
| 165 | /> | |
| 166 | ) : null} | |
| 167 | <SettingsSection title="Auto-settle"> | |
| 168 | <SettingsSwitchRow | |
| 169 | icon="arrow.triangle.branch" | |
| 170 | label="Auto-settle merged threads" | |
| 171 | value={referenceSettings.sidebarAutoSettleOnMerge} | |
| 172 | disabled={disabled} | |
| 173 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} | |
| 174 | /> | |
| 175 | <SettingsSwitchRow | |
| 176 | icon="clock" | |
| 177 | label="Auto-settle inactive threads" | |
| 178 | value={afterDays !== null} | |
| 179 | disabled={disabled} | |
| 180 | onValueChange={(value) => | |
| 181 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) | |
| 182 | } | |
| 183 | /> | |
| 184 | {afterDays !== null ? ( | |
| 185 | <View | |
| 186 | className={cn( | |
| 187 | "flex-row items-center gap-4 px-4", | |
| 188 | Platform.OS === "android" ? "min-h-14 py-3" : "py-4", | |
| 189 | )} | |
| 190 | > | |
| 191 | <View style={{ width: Platform.OS === "android" ? 24 : 22 }} /> | |
| 192 | <Text | |
| 193 | className={cn( | |
| 194 | "flex-1 text-foreground", | |
| 195 | Platform.OS === "android" ? "text-base" : "text-lg", | |
| 196 | )} | |
| 197 | > | |
| 198 | Inactive days | |
| 199 | </Text> | |
| 200 | <AutoSettleDaysField | |
| 201 | value={afterDays} | |
| 202 | disabled={disabled} | |
| 203 | onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })} | |
| 204 | /> | |
| 205 | </View> | |
| 206 | ) : null} | |
| 207 | </SettingsSection> | |
| 208 | {pendingWrites === 0 && mismatches.length > 0 ? ( | |
| 209 | <SettingsSection title="Across environments"> | |
| 210 | <View className="gap-3 p-4"> | |
| 211 | <Text className="text-base text-foreground">Auto-settle defaults differ</Text> | |
| 212 | <Text className="text-sm text-foreground-muted"> | |
| 213 | {mismatches.map((mismatch) => mismatch.label).join(", ")} | |
| 214 | </Text> | |
| 215 | <Pressable | |
| 216 | accessibilityRole="button" | |
| 217 | disabled={disabled} | |
| 218 | onPress={() => writeToAll(autoSettlePatch)} | |
| 219 | className="self-start rounded-full bg-subtle px-4 py-2 active:opacity-70" | |
| 220 | > | |
| 221 | <Text className="text-sm font-t3-medium text-foreground"> | |
| 222 | Apply auto-settle defaults | |
| 223 | </Text> | |
| 224 | </Pressable> | |
| 225 | </View> | |
| 226 | </SettingsSection> | |
| 227 | ) : null} | |
| 228 | </View> | |
| 229 | ); | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is | |
| 234 | * the counterpart of web's Settings → General → Legacy features backed by | |
| 235 | * mobile preferences. | |
| 236 | */ | |
| 237 | function LegacySettingsSection() { | |
| 238 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 239 | const preferences = useAtomValue(mobilePreferencesAtom); | |
| 240 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 241 | const planModeEnabled = | |
| 242 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | |
| 243 | ||
| 244 | return ( | |
| 245 | <View className="gap-3"> | |
| 246 | <SettingsSection title="Legacy"> | |
| 247 | <SettingsSwitchRow | |
| 248 | icon="sidebar.left" | |
| 249 | label="Legacy Thread List" | |
| 250 | value={!threadListV2Enabled} | |
| 251 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} | |
| 252 | /> | |
| 253 | <SettingsSwitchRow | |
| 254 | icon="hammer" | |
| 255 | label="Plan Mode" | |
| 256 | value={planModeEnabled} | |
| 257 | onValueChange={(value) => savePreferences({ planModeEnabled: value })} | |
| 258 | /> | |
| 259 | </SettingsSection> | |
| 260 | <Text className="px-2 text-sm text-foreground-muted"> | |
| 261 | Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan | |
| 262 | control; otherwise every task runs in Build mode. | |
| 263 | </Text> | |
| 264 | </View> | |
| 265 | ); | |
| 266 | } |
apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts+15-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; |
| ... | ... | @@ -75,4 +75,18 @@ describe("auto-settle settings sync", () => { |
| 75 | 75 | |
| 76 | 76 | expect(plan.mismatches).toEqual([]); |
| 77 | 77 | }); |
| 78 | ||
| 79 | it("notices different project checkouts on the same environment", () => { | |
| 80 | const projectReference = { ...reference, projectId: ProjectId.make("one") }; | |
| 81 | const otherCheckout = { | |
| 82 | environmentId: reference.environmentId, | |
| 83 | projectId: ProjectId.make("two"), | |
| 84 | label: "Other checkout", | |
| 85 | settings: { ...reference.settings, sidebarAutoSettleAfterDays: null }, | |
| 86 | }; | |
| 87 | ||
| 88 | expect(planAutoSettleSettingsSync(projectReference, [otherCheckout]).mismatches).toEqual([ | |
| 89 | otherCheckout, | |
| 90 | ]); | |
| 91 | }); | |
| 78 | 92 | }); |
apps/mobile/src/features/settings/autoSettleSettingsSync.ts+9-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ProjectId, ServerSettings } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | export type AutoSettleSettings = Pick< |
| 4 | 4 | ServerSettings, |
| ... | ... | @@ -7,13 +7,18 @@ export type AutoSettleSettings = Pick< |
| 7 | 7 | |
| 8 | 8 | interface AutoSettleSyncTarget { |
| 9 | 9 | readonly environmentId: EnvironmentId; |
| 10 | readonly projectId?: ProjectId | null; | |
| 10 | 11 | readonly label: string; |
| 11 | 12 | readonly settings: AutoSettleSettings | null; |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ |
| 15 | 16 | export function planAutoSettleSettingsSync( |
| 16 | reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, | |
| 17 | reference: { | |
| 18 | readonly environmentId: EnvironmentId; | |
| 19 | readonly projectId?: ProjectId | null; | |
| 20 | readonly settings: AutoSettleSettings; | |
| 21 | }, | |
| 17 | 22 | targets: readonly AutoSettleSyncTarget[], |
| 18 | 23 | ) { |
| 19 | 24 | const patch: AutoSettleSettings = { |
| ... | ... | @@ -22,7 +27,8 @@ export function planAutoSettleSettingsSync( |
| 22 | 27 | }; |
| 23 | 28 | const mismatches = targets.filter( |
| 24 | 29 | (target) => |
| 25 | target.environmentId !== reference.environmentId && | |
| 30 | (target.environmentId !== reference.environmentId || | |
| 31 | target.projectId !== reference.projectId) && | |
| 26 | 32 | target.settings !== null && |
| 27 | 33 | (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || |
| 28 | 34 | target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), |
apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx+3-2| ... | ... | @@ -10,6 +10,7 @@ import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; |
| 10 | 10 | |
| 11 | 11 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 12 | 12 | const adjust = (amount: number) => { |
| 13 | if (props.disabled) return; | |
| 13 | 14 | const next = Math.max( |
| 14 | 15 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 15 | 16 | Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), |
| ... | ... | @@ -28,7 +29,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 28 | 29 | <MaterialIconButton |
| 29 | 30 | icon="minus" |
| 30 | 31 | accessibilityLabel="Decrease days before auto-settle" |
| 31 | disabled={props.value <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} | |
| 32 | disabled={props.disabled || props.value <= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} | |
| 32 | 33 | onPress={() => adjust(-1)} |
| 33 | 34 | /> |
| 34 | 35 | <AppText |
| ... | ... | @@ -42,7 +43,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 42 | 43 | <MaterialIconButton |
| 43 | 44 | icon="plus" |
| 44 | 45 | accessibilityLabel="Increase days before auto-settle" |
| 45 | disabled={props.value >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} | |
| 46 | disabled={props.disabled || props.value >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} | |
| 46 | 47 | onPress={() => adjust(1)} |
| 47 | 48 | /> |
| 48 | 49 | </View> |
apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx+4-1| ... | ... | @@ -2,6 +2,7 @@ import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/sw |
| 2 | 2 | import { |
| 3 | 3 | accessibilityLabel, |
| 4 | 4 | buttonStyle, |
| 5 | disabled, | |
| 5 | 6 | font, |
| 6 | 7 | foregroundStyle, |
| 7 | 8 | frame, |
| ... | ... | @@ -34,11 +35,13 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 34 | 35 | <Popover.Trigger> |
| 35 | 36 | <Button |
| 36 | 37 | onPress={() => { |
| 38 | if (props.disabled) return; | |
| 37 | 39 | setDraft(props.value); |
| 38 | 40 | setOpen(true); |
| 39 | 41 | }} |
| 40 | 42 | modifiers={[ |
| 41 | 43 | buttonStyle("bordered"), |
| 44 | disabled(props.disabled), | |
| 42 | 45 | accessibilityLabel(`Days before auto-settle: ${props.value}`), |
| 43 | 46 | frame({ minWidth: 64, minHeight: 44 }), |
| 44 | 47 | foregroundStyle(colors["--color-primary"]), |
| ... | ... | @@ -81,7 +84,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 81 | 84 | label="Done" |
| 82 | 85 | onPress={() => { |
| 83 | 86 | setOpen(false); |
| 84 | if (draft !== props.value) props.onValueChange(draft); | |
| 87 | if (!props.disabled && draft !== props.value) props.onValueChange(draft); | |
| 85 | 88 | }} |
| 86 | 89 | modifiers={[foregroundStyle(colors["--color-primary"])]} |
| 87 | 90 | /> |
apps/mobile/src/features/settings/components/AutoSettleDaysField.tsx+6| ... | ... | @@ -9,11 +9,16 @@ import { AppTextInput } from "../../../components/AppText"; |
| 9 | 9 | export interface AutoSettleDaysFieldProps { |
| 10 | 10 | readonly value: number; |
| 11 | 11 | readonly onValueChange: (value: number) => void; |
| 12 | readonly disabled?: boolean; | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 15 | 16 | const [draft, setDraft] = useState<string | null>(null); |
| 16 | 17 | const commit = () => { |
| 18 | if (props.disabled) { | |
| 19 | setDraft(null); | |
| 20 | return; | |
| 21 | } | |
| 17 | 22 | const text = (draft ?? "").trim(); |
| 18 | 23 | setDraft(null); |
| 19 | 24 | // Validate the whole input; decimals and trailing text must not become whole days. |
| ... | ... | @@ -37,6 +42,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 37 | 42 | onBlur={commit} |
| 38 | 43 | onSubmitEditing={commit} |
| 39 | 44 | accessibilityLabel="Days before auto-settle" |
| 45 | editable={!props.disabled} | |
| 40 | 46 | /> |
| 41 | 47 | ); |
| 42 | 48 | } |
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx created+187| ... | ... | @@ -0,0 +1,187 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import { Platform, Pressable } from "react-native"; | |
| 3 | ||
| 4 | import { ControlPillMenu } from "../../../components/ControlPill"; | |
| 5 | import { SymbolView } from "../../../components/AppSymbol"; | |
| 6 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 7 | import { withNativeGlassHeaderItem } from "../../layout/native-glass-header-items"; | |
| 8 | import { useSettingsEnvironmentFilter } from "../settings-environment-filter"; | |
| 9 | ||
| 10 | export function SettingsEnvironmentFilterHeader(props: { readonly closeSettings?: boolean }) { | |
| 11 | const navigation = useNavigation(); | |
| 12 | const { | |
| 13 | availableTargets, | |
| 14 | selectedTargets, | |
| 15 | selectedIds, | |
| 16 | selectAll, | |
| 17 | toggleEnvironment, | |
| 18 | selectableProjectGroups, | |
| 19 | selectedProjectKey, | |
| 20 | selectProject, | |
| 21 | } = useSettingsEnvironmentFilter(); | |
| 22 | if (Platform.OS !== "ios") return null; | |
| 23 | ||
| 24 | const filterIcon = | |
| 25 | selectedIds === null && selectedProjectKey === null | |
| 26 | ? "line.3.horizontal.decrease" | |
| 27 | : "line.3.horizontal.decrease.circle.fill"; | |
| 28 | const filterVersion = JSON.stringify({ | |
| 29 | selection: selectedIds === null ? null : [...selectedIds].sort(), | |
| 30 | targets: availableTargets.map((entry) => [entry.environmentId, entry.label, entry.displayUrl]), | |
| 31 | project: selectedProjectKey, | |
| 32 | projects: selectableProjectGroups.map((group) => [group.key, group.label]), | |
| 33 | }); | |
| 34 | ||
| 35 | return ( | |
| 36 | <NativeStackScreenOptions | |
| 37 | optionsVersion={filterVersion} | |
| 38 | options={{ | |
| 39 | unstable_headerRightItems: () => [ | |
| 40 | withNativeGlassHeaderItem({ | |
| 41 | accessibilityLabel: "Filter settings environments and projects", | |
| 42 | icon: { name: filterIcon, type: "sfSymbol" }, | |
| 43 | label: "", | |
| 44 | type: "menu", | |
| 45 | menu: { | |
| 46 | title: "Settings scope", | |
| 47 | items: [ | |
| 48 | { | |
| 49 | type: "submenu", | |
| 50 | label: | |
| 51 | selectedIds === null | |
| 52 | ? "All environments" | |
| 53 | : `${selectedTargets.length} environments`, | |
| 54 | items: [ | |
| 55 | { | |
| 56 | type: "action", | |
| 57 | label: "All connected environments", | |
| 58 | state: selectedIds === null ? "on" : undefined, | |
| 59 | onPress: selectAll, | |
| 60 | }, | |
| 61 | ...availableTargets.map((entry) => ({ | |
| 62 | type: "action" as const, | |
| 63 | label: entry.label, | |
| 64 | description: entry.displayUrl ?? undefined, | |
| 65 | state: | |
| 66 | selectedIds === null || selectedIds.has(entry.environmentId) | |
| 67 | ? ("on" as const) | |
| 68 | : undefined, | |
| 69 | onPress: () => toggleEnvironment(entry.environmentId), | |
| 70 | })), | |
| 71 | ], | |
| 72 | }, | |
| 73 | { | |
| 74 | type: "submenu", | |
| 75 | label: | |
| 76 | selectableProjectGroups.find((group) => group.key === selectedProjectKey) | |
| 77 | ?.label ?? | |
| 78 | (selectedProjectKey === null ? "All projects" : "Unavailable project"), | |
| 79 | items: [ | |
| 80 | { | |
| 81 | type: "action", | |
| 82 | label: "All projects", | |
| 83 | state: selectedProjectKey === null ? "on" : undefined, | |
| 84 | onPress: () => selectProject(null), | |
| 85 | }, | |
| 86 | ...selectableProjectGroups.map((group) => ({ | |
| 87 | type: "action" as const, | |
| 88 | label: group.label, | |
| 89 | state: selectedProjectKey === group.key ? ("on" as const) : undefined, | |
| 90 | onPress: () => selectProject(group.key), | |
| 91 | })), | |
| 92 | ], | |
| 93 | }, | |
| 94 | ], | |
| 95 | }, | |
| 96 | }), | |
| 97 | ...(props.closeSettings | |
| 98 | ? [ | |
| 99 | withNativeGlassHeaderItem({ | |
| 100 | accessibilityLabel: "Close settings", | |
| 101 | icon: { name: "xmark", type: "sfSymbol" }, | |
| 102 | identifier: "settings-close", | |
| 103 | label: "", | |
| 104 | onPress: () => navigation.goBack(), | |
| 105 | type: "button", | |
| 106 | }), | |
| 107 | ] | |
| 108 | : []), | |
| 109 | ], | |
| 110 | }} | |
| 111 | /> | |
| 112 | ); | |
| 113 | } | |
| 114 | ||
| 115 | export function AndroidSettingsEnvironmentFilter() { | |
| 116 | const { | |
| 117 | availableTargets, | |
| 118 | selectedIds, | |
| 119 | selectAll, | |
| 120 | toggleEnvironment, | |
| 121 | selectableProjectGroups, | |
| 122 | selectedProjectKey, | |
| 123 | selectProject, | |
| 124 | } = useSettingsEnvironmentFilter(); | |
| 125 | const filterIcon = | |
| 126 | selectedIds === null && selectedProjectKey === null | |
| 127 | ? "line.3.horizontal.decrease" | |
| 128 | : "line.3.horizontal.decrease.circle.fill"; | |
| 129 | ||
| 130 | return ( | |
| 131 | <ControlPillMenu | |
| 132 | accessible | |
| 133 | accessibilityRole="button" | |
| 134 | accessibilityLabel="Filter settings environments and projects" | |
| 135 | title="Settings scope" | |
| 136 | actions={[ | |
| 137 | { | |
| 138 | id: "all", | |
| 139 | title: "All connected environments", | |
| 140 | state: selectedIds === null ? ("on" as const) : ("off" as const), | |
| 141 | }, | |
| 142 | ...availableTargets.map((entry) => ({ | |
| 143 | id: `environment:${entry.environmentId}`, | |
| 144 | title: `Environment · ${entry.label}`, | |
| 145 | subtitle: entry.displayUrl ?? undefined, | |
| 146 | state: | |
| 147 | selectedIds === null || selectedIds.has(entry.environmentId) | |
| 148 | ? ("on" as const) | |
| 149 | : ("off" as const), | |
| 150 | })), | |
| 151 | { | |
| 152 | id: "project:all", | |
| 153 | title: "All projects", | |
| 154 | state: selectedProjectKey === null ? ("on" as const) : ("off" as const), | |
| 155 | }, | |
| 156 | ...selectableProjectGroups.map((group) => ({ | |
| 157 | id: `project:${group.key}`, | |
| 158 | title: `Project · ${group.label}`, | |
| 159 | state: selectedProjectKey === group.key ? ("on" as const) : ("off" as const), | |
| 160 | })), | |
| 161 | ]} | |
| 162 | onPressAction={({ nativeEvent }) => { | |
| 163 | if (nativeEvent.event === "all") selectAll(); | |
| 164 | else if (nativeEvent.event === "project:all") selectProject(null); | |
| 165 | else if (nativeEvent.event.startsWith("project:")) { | |
| 166 | const group = selectableProjectGroups.find( | |
| 167 | (entry) => `project:${entry.key}` === nativeEvent.event, | |
| 168 | ); | |
| 169 | if (group) selectProject(group.key); | |
| 170 | } else { | |
| 171 | const target = availableTargets.find( | |
| 172 | (entry) => `environment:${entry.environmentId}` === nativeEvent.event, | |
| 173 | ); | |
| 174 | if (target) toggleEnvironment(target.environmentId); | |
| 175 | } | |
| 176 | }} | |
| 177 | > | |
| 178 | <Pressable | |
| 179 | accessibilityRole="button" | |
| 180 | accessibilityLabel="Filter settings environments and projects" | |
| 181 | className="size-11 items-center justify-center rounded-full" | |
| 182 | > | |
| 183 | <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" /> | |
| 184 | </Pressable> | |
| 185 | </ControlPillMenu> | |
| 186 | ); | |
| 187 | } |
apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { Pressable, View } from "react-native"; | |
| 2 | ||
| 3 | import { AppText as Text } from "../../../components/AppText"; | |
| 4 | import { SettingsSection } from "./SettingsSection"; | |
| 5 | ||
| 6 | export function SettingsProjectOverridesSection(props: { | |
| 7 | readonly projectLabel: string; | |
| 8 | readonly hasOverrides: boolean; | |
| 9 | readonly supportsOverrides: boolean; | |
| 10 | readonly pending: boolean; | |
| 11 | readonly onClear: () => void; | |
| 12 | }) { | |
| 13 | return ( | |
| 14 | <SettingsSection title="Project"> | |
| 15 | <View className="min-h-16 flex-row items-center gap-3 px-4 py-3"> | |
| 16 | <Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={2}> | |
| 17 | {props.projectLabel} | |
| 18 | </Text> | |
| 19 | {!props.pending && props.supportsOverrides && props.hasOverrides ? ( | |
| 20 | <Pressable | |
| 21 | accessibilityRole="button" | |
| 22 | accessibilityLabel="Use environment defaults" | |
| 23 | onPress={props.onClear} | |
| 24 | className="px-2 py-2 active:opacity-70" | |
| 25 | > | |
| 26 | <Text className="text-sm font-t3-medium text-primary">Use defaults</Text> | |
| 27 | </Pressable> | |
| 28 | ) : null} | |
| 29 | </View> | |
| 30 | {!props.supportsOverrides ? ( | |
| 31 | <Text className="px-4 pb-3 text-sm text-foreground-muted"> | |
| 32 | Update the selected environments to edit project overrides. | |
| 33 | </Text> | |
| 34 | ) : null} | |
| 35 | </SettingsSection> | |
| 36 | ); | |
| 37 | } |
apps/mobile/src/features/settings/components/SettingsSection.tsx+13-9| ... | ... | @@ -5,6 +5,7 @@ import { AppText as Text } from "../../../components/AppText"; |
| 5 | 5 | |
| 6 | 6 | export function SettingsSection(props: { |
| 7 | 7 | readonly title?: string; |
| 8 | readonly trailing?: ReactNode; | |
| 8 | 9 | readonly children: ReactNode; |
| 9 | 10 | /** Force the grouped card background; Android otherwise lists options flat. */ |
| 10 | 11 | readonly card?: boolean; |
| ... | ... | @@ -12,15 +13,18 @@ export function SettingsSection(props: { |
| 12 | 13 | return ( |
| 13 | 14 | <View className="gap-2"> |
| 14 | 15 | {props.title ? ( |
| 15 | <Text | |
| 16 | className={ | |
| 17 | Platform.OS === "android" | |
| 18 | ? "px-4 text-sm font-t3-medium text-primary" | |
| 19 | : "px-2 text-sm font-t3-medium text-foreground-muted" | |
| 20 | } | |
| 21 | > | |
| 22 | {props.title} | |
| 23 | </Text> | |
| 16 | <View className="flex-row items-center justify-between gap-3"> | |
| 17 | <Text | |
| 18 | className={ | |
| 19 | Platform.OS === "android" | |
| 20 | ? "px-4 text-sm font-t3-medium text-primary" | |
| 21 | : "px-2 text-sm font-t3-medium text-foreground-muted" | |
| 22 | } | |
| 23 | > | |
| 24 | {props.title} | |
| 25 | </Text> | |
| 26 | {props.trailing} | |
| 27 | </View> | |
| 24 | 28 | ) : null} |
| 25 | 29 | <View |
| 26 | 30 | className={ |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+9| ... | ... | @@ -1,7 +1,16 @@ |
| 1 | 1 | export type SettingsSheetTarget = |
| 2 | 2 | | "SettingsEnvironments" |
| 3 | | "SettingsNotifications" | |
| 4 | | "SettingsThreads" | |
| 5 | | "SettingsAbout" | |
| 3 | 6 | | "SettingsArchive" |
| 4 | 7 | | "SettingsAppearance" |
| 8 | | "SettingsOrganization" | |
| 9 | | "SettingsProjectOverview" | |
| 10 | | "SettingsEnvironmentNewThreads" | |
| 11 | | "SettingsEnvironmentSourceControl" | |
| 12 | | "SettingsEnvironmentAgentBehavior" | |
| 13 | | "SettingsEnvironmentMaintenance" | |
| 5 | 14 | | "SettingsKeyboard" |
| 6 | 15 | | "SettingsProjectGrouping" |
| 7 | 16 | | "SettingsClientStorage" |
apps/mobile/src/features/settings/settings-environment-filter.logic.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export function toggleSettingsEnvironment( | |
| 4 | selected: ReadonlySet<EnvironmentId> | null, | |
| 5 | available: readonly { readonly environmentId: EnvironmentId }[], | |
| 6 | toggledId: EnvironmentId, | |
| 7 | ): ReadonlySet<EnvironmentId> | null { | |
| 8 | const ids = available.map((entry) => entry.environmentId); | |
| 9 | const next = new Set(ids.filter((id) => selected === null || selected.has(id))); | |
| 10 | if (ids.includes(toggledId)) { | |
| 11 | if (next.has(toggledId)) next.delete(toggledId); | |
| 12 | else next.add(toggledId); | |
| 13 | } | |
| 14 | return ids.every((id) => next.has(id)) ? null : next; | |
| 15 | } |
apps/mobile/src/features/settings/settings-environment-filter.test.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { toggleSettingsEnvironment } from "./settings-environment-filter.logic"; | |
| 5 | ||
| 6 | const first = "first" as EnvironmentId; | |
| 7 | const second = "second" as EnvironmentId; | |
| 8 | const connected = [{ environmentId: first }, { environmentId: second }]; | |
| 9 | ||
| 10 | describe("settings environment filter", () => { | |
| 11 | it("starts with all connected environments and can narrow to either or neither", () => { | |
| 12 | const withoutFirst = toggleSettingsEnvironment(null, connected, first); | |
| 13 | expect([...withoutFirst!]).toEqual([second]); | |
| 14 | ||
| 15 | const none = toggleSettingsEnvironment(withoutFirst, connected, second); | |
| 16 | expect([...none!]).toEqual([]); | |
| 17 | ||
| 18 | const onlyFirst = toggleSettingsEnvironment(none, connected, first); | |
| 19 | expect([...onlyFirst!]).toEqual([first]); | |
| 20 | ||
| 21 | expect(toggleSettingsEnvironment(onlyFirst, connected, second)).toBeNull(); | |
| 22 | }); | |
| 23 | ||
| 24 | it("drops environments that are no longer connected when the selection changes", () => { | |
| 25 | const onlyFirst = new Set([first]); | |
| 26 | expect(toggleSettingsEnvironment(onlyFirst, [{ environmentId: second }], first)).toEqual( | |
| 27 | new Set(), | |
| 28 | ); | |
| 29 | }); | |
| 30 | }); |
apps/mobile/src/features/settings/settings-environment-filter.tsx created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; | |
| 3 | import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; | |
| 4 | ||
| 5 | import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; | |
| 6 | import { useProjects } from "../../state/entities"; | |
| 7 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | |
| 8 | import { toggleSettingsEnvironment } from "./settings-environment-filter.logic"; | |
| 9 | ||
| 10 | export type SettingsTarget = EnvironmentPresentation & { | |
| 11 | readonly serverConfig: NonNullable<EnvironmentPresentation["serverConfig"]>; | |
| 12 | }; | |
| 13 | ||
| 14 | function connectedSettingsTargets(environments: readonly EnvironmentPresentation[]) { | |
| 15 | return environments.filter( | |
| 16 | (entry): entry is SettingsTarget => | |
| 17 | entry.connection.phase === "connected" && entry.serverConfig !== null, | |
| 18 | ); | |
| 19 | } | |
| 20 | ||
| 21 | const SettingsEnvironmentFilterContext = createContext<{ | |
| 22 | readonly availableTargets: readonly SettingsTarget[]; | |
| 23 | readonly selectedTargets: readonly SettingsTarget[]; | |
| 24 | readonly selectedIds: ReadonlySet<EnvironmentId> | null; | |
| 25 | readonly projectGroups: ReturnType<typeof buildProjectGroups>; | |
| 26 | readonly selectableProjectGroups: ReturnType<typeof buildProjectGroups>; | |
| 27 | readonly selectedProjectKey: string | null; | |
| 28 | readonly selectProject: (projectKey: string | null) => void; | |
| 29 | readonly selectAll: () => void; | |
| 30 | readonly toggleEnvironment: (environmentId: EnvironmentId) => void; | |
| 31 | } | null>(null); | |
| 32 | ||
| 33 | export function SettingsEnvironmentFilterProvider(props: { readonly children: ReactNode }) { | |
| 34 | const { environments } = useEnvironments(); | |
| 35 | const projects = useProjects(); | |
| 36 | const groupingSettings = useMobileProjectGroupingSettings(); | |
| 37 | const groupingMode = groupingSettings.sidebarProjectGroupingMode; | |
| 38 | const [selectedIds, setSelectedIds] = useState<ReadonlySet<EnvironmentId> | null>(null); | |
| 39 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | |
| 40 | const projectGroups = useMemo( | |
| 41 | () => | |
| 42 | buildProjectGroups({ | |
| 43 | projects, | |
| 44 | settings: { sidebarProjectGroupingMode: groupingMode, sidebarProjectGroupingOverrides: {} }, | |
| 45 | }), | |
| 46 | [projects, groupingMode], | |
| 47 | ); | |
| 48 | const availableTargets = useMemo(() => connectedSettingsTargets(environments), [environments]); | |
| 49 | const selectedTargets = useMemo( | |
| 50 | () => | |
| 51 | availableTargets.filter( | |
| 52 | (entry) => selectedIds === null || selectedIds.has(entry.environmentId), | |
| 53 | ), | |
| 54 | [availableTargets, selectedIds], | |
| 55 | ); | |
| 56 | const selectableProjectGroups = useMemo( | |
| 57 | () => | |
| 58 | projectGroups.filter((group) => | |
| 59 | group.members.some((member) => | |
| 60 | selectedTargets.some((target) => target.environmentId === member.project.environmentId), | |
| 61 | ), | |
| 62 | ), | |
| 63 | [projectGroups, selectedTargets], | |
| 64 | ); | |
| 65 | const value = useMemo( | |
| 66 | () => ({ | |
| 67 | availableTargets, | |
| 68 | selectedTargets, | |
| 69 | selectedIds, | |
| 70 | projectGroups, | |
| 71 | selectableProjectGroups, | |
| 72 | selectedProjectKey, | |
| 73 | selectProject: setSelectedProjectKey, | |
| 74 | selectAll: () => setSelectedIds(null), | |
| 75 | toggleEnvironment: (environmentId: EnvironmentId) => | |
| 76 | setSelectedIds((current) => | |
| 77 | toggleSettingsEnvironment(current, availableTargets, environmentId), | |
| 78 | ), | |
| 79 | }), | |
| 80 | [ | |
| 81 | availableTargets, | |
| 82 | selectedTargets, | |
| 83 | selectedIds, | |
| 84 | projectGroups, | |
| 85 | selectableProjectGroups, | |
| 86 | selectedProjectKey, | |
| 87 | ], | |
| 88 | ); | |
| 89 | return ( | |
| 90 | <SettingsEnvironmentFilterContext value={value}> | |
| 91 | {props.children} | |
| 92 | </SettingsEnvironmentFilterContext> | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | export function useSettingsEnvironmentFilter() { | |
| 97 | const filter = useContext(SettingsEnvironmentFilterContext); | |
| 98 | if (filter === null) throw new Error("Settings environment filter provider is missing."); | |
| 99 | return filter; | |
| 100 | } |
apps/mobile/src/features/settings/settings-scoped-server.test.ts created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { | |
| 2 | DEFAULT_SERVER_SETTINGS, | |
| 3 | type EnvironmentId, | |
| 4 | type ProjectId, | |
| 5 | type ServerSettings, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { describe, expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import type { SettingsTarget } from "./settings-environment-filter"; | |
| 10 | import { | |
| 11 | planMobileScopedSettingsClear, | |
| 12 | planMobileScopedSettingsPatch, | |
| 13 | resolveMobileSettingsTargets, | |
| 14 | } from "./settings-scoped-server"; | |
| 15 | ||
| 16 | const firstId = "first" as EnvironmentId; | |
| 17 | const secondId = "second" as EnvironmentId; | |
| 18 | const firstProject = "first-project" as ProjectId; | |
| 19 | const secondProject = "second-project" as ProjectId; | |
| 20 | ||
| 21 | function environment(environmentId: EnvironmentId, settings: ServerSettings): SettingsTarget { | |
| 22 | return { | |
| 23 | environmentId, | |
| 24 | serverConfig: { | |
| 25 | settings, | |
| 26 | environment: { capabilities: { projectSettingsOverrides: true } }, | |
| 27 | }, | |
| 28 | } as SettingsTarget; | |
| 29 | } | |
| 30 | ||
| 31 | describe("mobile project settings scope", () => { | |
| 32 | it("edits each checkout's own override without changing either environment default", () => { | |
| 33 | const firstSettings: ServerSettings = { | |
| 34 | ...DEFAULT_SERVER_SETTINGS, | |
| 35 | responseStreamingMode: "paragraph", | |
| 36 | projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } }, | |
| 37 | }; | |
| 38 | const secondSettings: ServerSettings = { | |
| 39 | ...DEFAULT_SERVER_SETTINGS, | |
| 40 | responseStreamingMode: "token", | |
| 41 | projectSettingsOverrides: {}, | |
| 42 | }; | |
| 43 | const targets = resolveMobileSettingsTargets( | |
| 44 | [environment(firstId, firstSettings), environment(secondId, secondSettings)], | |
| 45 | [ | |
| 46 | { environmentId: firstId, id: firstProject }, | |
| 47 | { environmentId: secondId, id: secondProject }, | |
| 48 | ], | |
| 49 | ); | |
| 50 | ||
| 51 | const writes = planMobileScopedSettingsPatch(targets, true, { | |
| 52 | responseStreamingMode: "turn", | |
| 53 | }); | |
| 54 | expect(writes).toEqual([ | |
| 55 | { | |
| 56 | environmentId: firstId, | |
| 57 | patch: { | |
| 58 | projectSettingsOverrides: { | |
| 59 | [firstProject]: { defaultAutoPull: true, responseStreamingMode: "turn" }, | |
| 60 | }, | |
| 61 | }, | |
| 62 | }, | |
| 63 | { | |
| 64 | environmentId: secondId, | |
| 65 | patch: { projectSettingsOverrides: { [secondProject]: { responseStreamingMode: "turn" } } }, | |
| 66 | }, | |
| 67 | ]); | |
| 68 | expect(firstSettings.responseStreamingMode).toBe("paragraph"); | |
| 69 | expect(secondSettings.responseStreamingMode).toBe("token"); | |
| 70 | }); | |
| 71 | ||
| 72 | it("resets only the selected page's override and rejects environment-wide writes", () => { | |
| 73 | const settings: ServerSettings = { | |
| 74 | ...DEFAULT_SERVER_SETTINGS, | |
| 75 | projectSettingsOverrides: { | |
| 76 | [firstProject]: { defaultAutoPull: true, responseStreamingMode: "turn" }, | |
| 77 | }, | |
| 78 | }; | |
| 79 | const targets = resolveMobileSettingsTargets( | |
| 80 | [environment(firstId, settings)], | |
| 81 | [{ environmentId: firstId, id: firstProject }], | |
| 82 | ); | |
| 83 | ||
| 84 | expect(planMobileScopedSettingsClear(targets, ["responseStreamingMode"])).toEqual([ | |
| 85 | { | |
| 86 | environmentId: firstId, | |
| 87 | patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, | |
| 88 | }, | |
| 89 | ]); | |
| 90 | expect( | |
| 91 | planMobileScopedSettingsPatch(targets, true, { enableProviderUpdateChecks: false }), | |
| 92 | ).toEqual([]); | |
| 93 | }); | |
| 94 | }); |
apps/mobile/src/features/settings/settings-scoped-server.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import { | |
| 2 | PROJECT_SCOPED_SERVER_SETTING_KEYS, | |
| 3 | type EnvironmentId, | |
| 4 | type ProjectId, | |
| 5 | type ProjectScopedServerSettingKey, | |
| 6 | type ServerSettings, | |
| 7 | type ServerSettingsPatch, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | clearProjectSettingsOverrides, | |
| 11 | resolveProjectSettings, | |
| 12 | } from "@t3tools/shared/projectSettings"; | |
| 13 | ||
| 14 | import type { SettingsTarget } from "./settings-environment-filter"; | |
| 15 | ||
| 16 | export interface ScopedMobileSettingsTarget { | |
| 17 | readonly environment: SettingsTarget; | |
| 18 | readonly projectId: ProjectId | null; | |
| 19 | readonly settings: ServerSettings; | |
| 20 | readonly sources: ReturnType<typeof resolveProjectSettings>["sources"]; | |
| 21 | } | |
| 22 | ||
| 23 | export function resolveMobileSettingsTargets( | |
| 24 | environments: readonly SettingsTarget[], | |
| 25 | members: readonly { readonly environmentId: EnvironmentId; readonly id: ProjectId }[] | null, | |
| 26 | ): readonly ScopedMobileSettingsTarget[] { | |
| 27 | if (members === null) { | |
| 28 | return environments.map((environment) => ({ | |
| 29 | environment, | |
| 30 | projectId: null, | |
| 31 | ...resolveProjectSettings(environment.serverConfig.settings, null), | |
| 32 | })); | |
| 33 | } | |
| 34 | const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); | |
| 35 | return members.flatMap((member) => { | |
| 36 | const environment = byId.get(member.environmentId); | |
| 37 | if (!environment) return []; | |
| 38 | return [ | |
| 39 | { | |
| 40 | environment, | |
| 41 | projectId: member.id, | |
| 42 | ...resolveProjectSettings(environment.serverConfig.settings, member.id), | |
| 43 | }, | |
| 44 | ]; | |
| 45 | }); | |
| 46 | } | |
| 47 | ||
| 48 | export function planMobileScopedSettingsPatch( | |
| 49 | targets: readonly ScopedMobileSettingsTarget[], | |
| 50 | projectSelected: boolean, | |
| 51 | patch: ServerSettingsPatch, | |
| 52 | ) { | |
| 53 | if (!projectSelected) { | |
| 54 | return targets.map((target) => ({ environmentId: target.environment.environmentId, patch })); | |
| 55 | } | |
| 56 | const keys = Object.keys(patch); | |
| 57 | if ( | |
| 58 | keys.some( | |
| 59 | (key) => !PROJECT_SCOPED_SERVER_SETTING_KEYS.includes(key as ProjectScopedServerSettingKey), | |
| 60 | ) | |
| 61 | ) | |
| 62 | return []; | |
| 63 | const writes = new Map<EnvironmentId, Record<string, unknown>>(); | |
| 64 | for (const target of targets) { | |
| 65 | if ( | |
| 66 | target.projectId === null || | |
| 67 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true | |
| 68 | ) | |
| 69 | continue; | |
| 70 | const current = | |
| 71 | target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; | |
| 72 | const next = { ...current, ...patch }; | |
| 73 | const overrides = writes.get(target.environment.environmentId) ?? {}; | |
| 74 | overrides[target.projectId] = next; | |
| 75 | writes.set(target.environment.environmentId, overrides); | |
| 76 | } | |
| 77 | return [...writes].map(([environmentId, projectSettingsOverrides]) => ({ | |
| 78 | environmentId, | |
| 79 | patch: { projectSettingsOverrides } as ServerSettingsPatch, | |
| 80 | })); | |
| 81 | } | |
| 82 | ||
| 83 | export function planMobileScopedSettingsClear( | |
| 84 | targets: readonly ScopedMobileSettingsTarget[], | |
| 85 | keys: readonly ProjectScopedServerSettingKey[], | |
| 86 | ) { | |
| 87 | const writes = new Map<EnvironmentId, Record<string, unknown>>(); | |
| 88 | for (const target of targets) { | |
| 89 | if ( | |
| 90 | target.projectId === null || | |
| 91 | target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true | |
| 92 | ) | |
| 93 | continue; | |
| 94 | const overrides = writes.get(target.environment.environmentId) ?? {}; | |
| 95 | overrides[target.projectId] = clearProjectSettingsOverrides( | |
| 96 | target.environment.serverConfig.settings, | |
| 97 | target.projectId, | |
| 98 | keys, | |
| 99 | ); | |
| 100 | writes.set(target.environment.environmentId, overrides); | |
| 101 | } | |
| 102 | return [...writes].map(([environmentId, projectSettingsOverrides]) => ({ | |
| 103 | environmentId, | |
| 104 | patch: { projectSettingsOverrides } as ServerSettingsPatch, | |
| 105 | })); | |
| 106 | } |
docs/user/appearance.md+6-5| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | # Appearance and themes |
| 2 | 2 | |
| 3 | Open **Settings → Appearance** to choose a theme and follow the system appearance or stay in light | |
| 4 | or dark mode. To use different themes for light and dark mode, select the corresponding preview | |
| 5 | within each theme. Appearance preferences are saved separately on each device or browser. | |
| 3 | On web and desktop, open **Settings → Appearance** to choose a theme and follow the system | |
| 4 | appearance or stay in light or dark mode. To use different themes for light and dark mode, select | |
| 5 | the corresponding preview within each theme. Appearance preferences are saved separately on each | |
| 6 | device or browser. | |
| 6 | 7 | |
| 7 | 8 | On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. |
| 8 | 9 | Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly. |
| ... | ... | @@ -10,8 +11,8 @@ Use **Change appearance** in the command palette to choose System, Light, or Dar |
| 10 | 11 | the theme. **Cmd+Option+Shift+A** on macOS or **Ctrl+Alt+Shift+A** on Windows/Linux cycles through |
| 11 | 12 | those modes. Customize these shortcuts under **Settings → Keybindings**. |
| 12 | 13 | |
| 13 | Mobile has its own themes and text, code, and terminal preferences. It does not follow environment | |
| 14 | themes or defaults. | |
| 14 | On mobile, open **Settings → Appearance**. Mobile has its own themes and text, | |
| 15 | code, and terminal preferences. It does not follow environment themes or defaults. | |
| 15 | 16 | |
| 16 | 17 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from |
| 17 | 18 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You |
docs/user/mobile-notifications.md+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | # Mobile notifications |
| 2 | 2 | |
| 3 | Sign in to T3 Connect, link your environments, and enable **Device Notifications** in Settings to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. | |
| 3 | Sign in to T3 Connect, link your environments, and enable **Device Notifications** in **Settings → Notifications** to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. | |
| 4 | 4 | |
| 5 | 5 | Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. |
| 6 | 6 |
docs/user/open-source-licenses.md+1-1| ... | ... | @@ -5,7 +5,7 @@ notices: |
| 5 | 5 | |
| 6 | 6 | - On web and desktop, open **Settings → General**, find **Open source licenses** under **About**, |
| 7 | 7 | and select **View licenses**. |
| 8 | - On mobile, open **Settings → App → Open source licenses**. | |
| 8 | - On mobile, open **Settings → About T3 Code → Open source licenses**. | |
| 9 | 9 | |
| 10 | 10 | The page lists the version when available, license identifier, and the parts of T3 Code that include |
| 11 | 11 | or use the item. This also covers optional device tools that T3 Code installs on demand instead of |
docs/user/project-settings.md+13-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | # Settings and project overrides |
| 2 | 2 | |
| 3 | The Settings breadcrumb ends with the environment and project a change applies to. They start | |
| 3 | On web and desktop, the Settings breadcrumb ends with the environment and project a change applies to. They start | |
| 4 | 4 | at **All environments** and **All projects** and stay selected as you move between categories or |
| 5 | 5 | search for a setting. |
| 6 | 6 | |
| ... | ... | @@ -26,6 +26,17 @@ again. |
| 26 | 26 | Providers and diagnostics are per machine: they show one environment at a time, the primary |
| 27 | 27 | one until you pick another. Every other setting fans out to the selection. |
| 28 | 28 | |
| 29 | On mobile, open **Settings** and use the filter in its header to choose connected environments | |
| 30 | and a project. The filter stays available in server-setting pages. With **All projects** selected, | |
| 31 | the **Server settings** categories and auto-settle controls in **Thread behavior** edit the | |
| 32 | selected environments' defaults. Choosing a project edits its overrides on the selected | |
| 33 | environments. Use **Use defaults** in a page to remove that page's project overrides. | |
| 34 | Open **Settings → Projects & threads → Overview** to rename the project across its selected | |
| 35 | connected checkouts and see where those checkouts live. | |
| 36 | Settings that are environment-wide stay read-only while a project is selected. When selected | |
| 37 | targets disagree, a control shows **Mixed** until you choose one value. Appearance, keyboard, | |
| 38 | and other phone-only settings ignore the filter. | |
| 39 | ||
| 29 | 40 | ## Defaults and inheritance |
| 30 | 41 | |
| 31 | 42 | General contains the model and workspace for new threads. Integrations controls agent browser |
| ... | ... | @@ -83,6 +94,7 @@ from the icon palette, derived from the saved project name. For example, `Nebula |
| 83 | 94 | |
| 84 | 95 | In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date |
| 85 | 96 | with its configured upstream. Choose an environment to set the default or a project to override it. |
| 97 | On mobile, use **Settings → Source control** to change selected environment defaults or project overrides. | |
| 86 | 98 | |
| 87 | 99 | T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, |
| 88 | 100 | or local commits. It skips checkouts on another branch or without an upstream. If a checkout has |
docs/user/thread-sidebar.md+3-3| ... | ... | @@ -93,9 +93,9 @@ prevent automatic settlement. An open pull request does not prevent inactivity |
| 93 | 93 | settlement, but an old closed or merged pull request does not settle work you |
| 94 | 94 | resumed after it closed. |
| 95 | 95 | |
| 96 | Change these rules in **Settings → General**. They continue to run when your apps | |
| 97 | are closed. On web and desktop, choose an environment at the top to change only | |
| 98 | its rules, or **All environments** to update connected environments together. | |
| 96 | Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile. | |
| 97 | They continue to run when your apps are closed. On web and desktop, choose an environment at the | |
| 98 | top to change only its rules, or **All environments** to update connected environments together. | |
| 99 | 99 | Mixed values show where the selected environments disagree. Mobile applies these |
| 100 | 100 | rules to connected environments that support shared settings. Offline environments |
| 101 | 101 | and older servers keep their previous values. Changing a rule does not reopen |