| 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,6 +59,12 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl |
| 59 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; | 59 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; |
| 60 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 60 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 61 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 61 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 62 | import { | ||
| 63 | SettingsEnvironmentAgentBehaviorRouteScreen, | ||
| 64 | SettingsEnvironmentMaintenanceRouteScreen, | ||
| 65 | SettingsEnvironmentNewThreadsRouteScreen, | ||
| 66 | SettingsEnvironmentSourceControlRouteScreen, | ||
| 67 | } from "./features/settings/SettingsServerControlsRouteScreen"; | ||
| 62 | import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; | 68 | import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; |
| 63 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 69 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 64 | import { | 70 | import { |
| ... | @@ -66,9 +72,14 @@ import { | ... | @@ -66,9 +72,14 @@ import { |
| 66 | SettingsOpenSourceLicensesRouteScreen, | 72 | SettingsOpenSourceLicensesRouteScreen, |
| 67 | } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; | 73 | } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; |
| 68 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | 74 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 75 | import { SettingsProjectOverviewRouteScreen } from "./features/settings/SettingsProjectOverviewRouteScreen"; | ||
| 69 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; | 76 | import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; |
| 70 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; | 77 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 78 | import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; | ||
| 79 | import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen"; | ||
| 71 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 80 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 81 | import { SettingsThreadsRouteScreen } from "./features/settings/SettingsThreadsRouteScreen"; | ||
| 82 | import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; | ||
| 72 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | 83 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 73 | import { | 84 | import { |
| 74 | SettingsLegalDocumentCloseHeaderButton, | 85 | SettingsLegalDocumentCloseHeaderButton, |
| ... | @@ -168,6 +179,41 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -168,6 +179,41 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 168 | title: "Environments", | 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 | SettingsEnvironmentNew: createNativeStackScreen({ | 217 | SettingsEnvironmentNew: createNativeStackScreen({ |
| 172 | screen: ConnectionsNewRouteScreen, | 218 | screen: ConnectionsNewRouteScreen, |
| 173 | linking: "environment-new", | 219 | linking: "environment-new", |
| ... | @@ -193,9 +239,21 @@ const SettingsContentStack = createNativeStackNavigator({ | ... | @@ -193,9 +239,21 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 193 | screen: SettingsProjectGroupingRouteScreen, | 239 | screen: SettingsProjectGroupingRouteScreen, |
| 194 | linking: "project-grouping", | 240 | linking: "project-grouping", |
| 195 | options: { | 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 | SettingsKeyboard: createNativeStackScreen({ | 257 | SettingsKeyboard: createNativeStackScreen({ |
| 200 | screen: SettingsKeyboardRouteScreen, | 258 | screen: SettingsKeyboardRouteScreen, |
| 201 | linking: "keyboard", | 259 | linking: "keyboard", |
| ... | @@ -257,6 +315,9 @@ const SettingsSheetStack = createNativeStackNavigator({ | ... | @@ -257,6 +315,9 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 257 | SettingsContent: createNativeStackScreen({ | 315 | SettingsContent: createNativeStackScreen({ |
| 258 | screen: SettingsContentStack, | 316 | screen: SettingsContentStack, |
| 259 | linking: "", | 317 | linking: "", |
| 318 | layout: ({ children }) => ( | ||
| 319 | <SettingsEnvironmentFilterProvider>{children}</SettingsEnvironmentFilterProvider> | ||
| 320 | ), | ||
| 260 | }), | 321 | }), |
| 261 | SettingsAuth: createNativeStackScreen({ | 322 | SettingsAuth: createNativeStackScreen({ |
| 262 | screen: SettingsAuthRouteScreen, | 323 | screen: SettingsAuthRouteScreen, |
| ... | @@ -632,7 +693,7 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -632,7 +693,7 @@ export const RootStack = createNativeStackNavigator({ |
| 632 | ? { presentation: "card" as const } | 693 | ? { presentation: "card" as const } |
| 633 | : { | 694 | : { |
| 634 | ...FORM_SHEET_PRESENTATION_OPTIONS, | 695 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 635 | sheetAllowedDetents: [0.7, 0.92], | 696 | sheetAllowedDetents: [0.92], |
| 636 | sheetGrabberVisible: true, | 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,7 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 47 | : null; | 47 | : null; |
| 48 | 48 | ||
| 49 | return ( | 49 | return ( |
| 50 | <SettingsScreen title="Project Grouping"> | 50 | <SettingsScreen title="Organization"> |
| 51 | <ScrollView | 51 | <ScrollView |
| 52 | contentInsetAdjustmentBehavior="automatic" | 52 | contentInsetAdjustmentBehavior="automatic" |
| 53 | showsVerticalScrollIndicator={false} | 53 | showsVerticalScrollIndicator={false} |
| ... | @@ -55,7 +55,7 @@ export function SettingsProjectGroupingRouteScreen() { | ... | @@ -55,7 +55,7 @@ export function SettingsProjectGroupingRouteScreen() { |
| 55 | contentContainerClassName="gap-3 px-5 pt-4" | 55 | contentContainerClassName="gap-3 px-5 pt-4" |
| 56 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} | 56 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 57 | > | 57 | > |
| 58 | <SettingsSection title="Default grouping"> | 58 | <SettingsSection title="Project grouping"> |
| 59 | {GROUPING_OPTIONS.map((option, index) => ( | 59 | {GROUPING_OPTIONS.map((option, index) => ( |
| 60 | <Pressable | 60 | <Pressable |
| 61 | key={option.mode} | 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,83 +1,23 @@ |
| 1 | import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; | ||
| 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 3 | import { useAuth, useUser } from "@clerk/expo"; | 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 | import { useNavigation } from "@react-navigation/native"; | 3 | import { useNavigation } from "@react-navigation/native"; |
| 8 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 4 | import { Platform, View } from "react-native"; |
| 9 | import { SymbolView } from "../../components/AppSymbol"; | 5 | import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; |
| 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"; | ||
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | 7 | ||
| 16 | import { | 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 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"; | ||
| 39 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 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 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 10 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 56 | import { SettingsRow } from "./components/SettingsRow"; | 11 | import { SettingsRow } from "./components/SettingsRow"; |
| 57 | import { SettingsSection } from "./components/SettingsSection"; | 12 | import { SettingsSection } from "./components/SettingsSection"; |
| 58 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | ||
| 59 | import { SettingsScreen } from "./components/SettingsScreen"; | 13 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 60 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | 14 | import { |
| 61 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; | 15 | AndroidSettingsEnvironmentFilter, |
| 62 | 16 | SettingsEnvironmentFilterHeader, | |
| 63 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | 17 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 64 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | 18 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; |
| 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 | } | ||
| 78 | 19 | ||
| 79 | export function SettingsRouteScreen() { | 20 | export function SettingsRouteScreen() { |
| 80 | const navigation = useNavigation(); | ||
| 81 | const content = hasCloudPublicConfig() ? ( | 21 | const content = hasCloudPublicConfig() ? ( |
| 82 | <ConfiguredSettingsRouteScreen /> | 22 | <ConfiguredSettingsRouteScreen /> |
| 83 | ) : ( | 23 | ) : ( |
| ... | @@ -87,27 +27,11 @@ export function SettingsRouteScreen() { | ... | @@ -87,27 +27,11 @@ export function SettingsRouteScreen() { |
| 87 | return ( | 27 | return ( |
| 88 | <> | 28 | <> |
| 89 | <WorkspaceSidebarToolbar /> | 29 | <WorkspaceSidebarToolbar /> |
| 90 | {Platform.OS !== "android" ? ( | 30 | <SettingsEnvironmentFilterHeader closeSettings /> |
| 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} | ||
| 109 | {Platform.OS === "android" ? ( | 31 | {Platform.OS === "android" ? ( |
| 110 | <SettingsScreen title="Settings">{content}</SettingsScreen> | 32 | <SettingsScreen title="Settings" trailing={<AndroidSettingsEnvironmentFilter />}> |
| 33 | {content} | ||
| 34 | </SettingsScreen> | ||
| 111 | ) : ( | 35 | ) : ( |
| 112 | content | 36 | content |
| 113 | )} | 37 | )} |
| ... | @@ -115,10 +39,17 @@ export function SettingsRouteScreen() { | ... | @@ -115,10 +39,17 @@ export function SettingsRouteScreen() { |
| 115 | ); | 39 | ); |
| 116 | } | 40 | } |
| 117 | 41 | ||
| 118 | function LocalSettingsRouteScreen() { | 42 | function ConfiguredSettingsRouteScreen() { |
| 119 | const insets = useSafeAreaInsets(); | 43 | const insets = useSafeAreaInsets(); |
| 44 | const navigation = useNavigation(); | ||
| 45 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 46 | const { user } = useUser(); | ||
| 120 | const { savedConnectionsById } = useSavedRemoteConnections(); | 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 | return ( | 54 | return ( |
| 124 | <View collapsable={false} className="flex-1 bg-sheet"> | 55 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | @@ -126,368 +57,37 @@ function LocalSettingsRouteScreen() { | ... | @@ -126,368 +57,37 @@ function LocalSettingsRouteScreen() { |
| 126 | contentInsetAdjustmentBehavior="automatic" | 57 | contentInsetAdjustmentBehavior="automatic" |
| 127 | showsVerticalScrollIndicator={false} | 58 | showsVerticalScrollIndicator={false} |
| 128 | className="flex-1" | 59 | className="flex-1" |
| 129 | contentContainerClassName="gap-6 px-5 pt-4" | 60 | contentContainerClassName="gap-4 px-5 pt-4" |
| 130 | contentContainerStyle={{ | 61 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 131 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 132 | }} | ||
| 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 | <SettingsRow | 71 | <SettingsRow |
| 136 | icon="desktopcomputer" | 72 | icon="desktopcomputer" |
| 137 | label="Environments" | 73 | label="Environments" |
| 138 | value={`${environmentCount}`} | 74 | value={`${Object.keys(savedConnectionsById).length}`} |
| 139 | valuePosition="trailing" | 75 | valuePosition="trailing" |
| 140 | target="SettingsEnvironments" | 76 | target="SettingsEnvironments" |
| 141 | /> | 77 | /> |
| 78 | <SettingsRow icon="bell.badge" label="Notifications" target="SettingsNotifications" /> | ||
| 142 | </SettingsSection> | 79 | </SettingsSection> |
| 143 | 80 | ||
| 144 | <GeneralSettingsSection /> | 81 | <SettingsIndexSections /> |
| 145 | |||
| 146 | <SettingsSection title="Appearance"> | ||
| 147 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | ||
| 148 | </SettingsSection> | ||
| 149 | |||
| 150 | <LegacySettingsSection /> | ||
| 151 | |||
| 152 | <ArchivedThreadsSettingsSection /> | ||
| 153 | |||
| 154 | <AppSettingsSection /> | ||
| 155 | </ScrollView> | 82 | </ScrollView> |
| 156 | </View> | 83 | </View> |
| 157 | ); | 84 | ); |
| 158 | } | 85 | } |
| 159 | 86 | ||
| 160 | function ConfiguredSettingsRouteScreen() { | 87 | function LocalSettingsRouteScreen() { |
| 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; | ||
| 169 | const insets = useSafeAreaInsets(); | 88 | const insets = useSafeAreaInsets(); |
| 170 | const navigation = useNavigation(); | ||
| 171 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 172 | const { user } = useUser(); | ||
| 173 | const { savedConnectionsById } = useSavedRemoteConnections(); | 89 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 174 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | 90 | const environmentCount = Object.keys(savedConnectionsById).length; |
| 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]); | ||
| 491 | 91 | ||
| 492 | return ( | 92 | return ( |
| 493 | <View collapsable={false} className="flex-1 bg-sheet"> | 93 | <View collapsable={false} className="flex-1 bg-sheet"> |
| ... | @@ -495,26 +95,12 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -495,26 +95,12 @@ function ConfiguredSettingsRouteScreen() { |
| 495 | contentInsetAdjustmentBehavior="automatic" | 95 | contentInsetAdjustmentBehavior="automatic" |
| 496 | showsVerticalScrollIndicator={false} | 96 | showsVerticalScrollIndicator={false} |
| 497 | className="flex-1" | 97 | className="flex-1" |
| 498 | contentContainerClassName="gap-6 px-5 pt-4" | 98 | contentContainerClassName="gap-4 px-5 pt-4" |
| 499 | contentContainerStyle={{ | 99 | contentContainerStyle={{ |
| 500 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 100 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 501 | }} | 101 | }} |
| 502 | > | 102 | > |
| 503 | <View className="gap-3"> | 103 | <SettingsSection title="Connections"> |
| 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"> | ||
| 518 | <SettingsRow | 104 | <SettingsRow |
| 519 | icon="desktopcomputer" | 105 | icon="desktopcomputer" |
| 520 | label="Environments" | 106 | label="Environments" |
| ... | @@ -522,353 +108,85 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -522,353 +108,85 @@ function ConfiguredSettingsRouteScreen() { |
| 522 | valuePosition="trailing" | 108 | valuePosition="trailing" |
| 523 | target="SettingsEnvironments" | 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 | </SettingsSection> | 111 | </SettingsSection> |
| 584 | 112 | ||
| 585 | <GeneralSettingsSection /> | 113 | <SettingsIndexSections /> |
| 586 | |||
| 587 | <SettingsSection title="Appearance"> | ||
| 588 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> | ||
| 589 | </SettingsSection> | ||
| 590 | |||
| 591 | <LegacySettingsSection /> | ||
| 592 | |||
| 593 | <ArchivedThreadsSettingsSection /> | ||
| 594 | |||
| 595 | <AppSettingsSection /> | ||
| 596 | </ScrollView> | 114 | </ScrollView> |
| 597 | </View> | 115 | </View> |
| 598 | ); | 116 | ); |
| 599 | } | 117 | } |
| 600 | 118 | ||
| 601 | function GeneralSettingsSection() { | 119 | function SettingsIndexSections() { |
| 602 | return ( | 120 | const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); |
| 603 | <SettingsSection title="General"> | 121 | const noServerTargets = selectedTargets.length === 0; |
| 604 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> | 122 | const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); |
| 605 | {Platform.OS === "ios" ? ( | 123 | const scopedProjectMembers = |
| 606 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> | 124 | selectedProject?.members |
| 607 | ) : null} | 125 | .map((member) => member.project) |
| 608 | <AutoSettleSettingsRows /> | 126 | .filter((project) => |
| 609 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | 127 | selectedTargets.some((target) => target.environmentId === project.environmentId), |
| 610 | </SettingsSection> | 128 | ) ?? []; |
| 611 | ); | 129 | const projectLabel = |
| 612 | } | 130 | scopedProjectMembers.length > 0 |
| 613 | 131 | ? deriveProjectGroupLabel({ | |
| 614 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | 132 | representative: scopedProjectMembers[0]!, |
| 615 | 133 | members: scopedProjectMembers, | |
| 616 | /** | 134 | }) |
| 617 | * Mobile edits auto-settle defaults across connected, capable environments. | 135 | : (selectedProject?.label ?? "Unavailable project"); |
| 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 | |||
| 657 | return ( | 136 | return ( |
| 658 | <> | 137 | <> |
| 659 | <SettingsSwitchRow | 138 | <SettingsSection title="Interface"> |
| 660 | icon="arrow.triangle.branch" | 139 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> |
| 661 | label="Auto-settle merged threads" | 140 | {Platform.OS === "ios" ? ( |
| 662 | value={referenceSettings.sidebarAutoSettleOnMerge} | 141 | <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" /> |
| 663 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} | 142 | ) : null} |
| 664 | /> | 143 | </SettingsSection> |
| 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 | } | ||
| 717 | 144 | ||
| 718 | /** | 145 | <SettingsSection title="Projects & threads"> |
| 719 | * Device-local legacy toggles. Mobile has no client-settings sync, so this is | 146 | {selectedProjectKey !== null ? ( |
| 720 | * the counterpart of web's Settings → General → Legacy features backed by | 147 | <SettingsRow |
| 721 | * mobile preferences. | 148 | icon="folder" |
| 722 | */ | 149 | label="Overview" |
| 723 | function LegacySettingsSection() { | 150 | value={projectLabel} |
| 724 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 151 | target="SettingsProjectOverview" |
| 725 | const preferences = useAtomValue(mobilePreferencesAtom); | 152 | /> |
| 726 | const threadListV2Enabled = useThreadListV2Enabled(); | 153 | ) : null} |
| 727 | const planModeEnabled = | 154 | <SettingsRow icon="folder" label="Organization" target="SettingsOrganization" /> |
| 728 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | 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 ( | 159 | <SettingsSection title="Server settings"> |
| 731 | <View className="gap-3"> | 160 | <SettingsRow |
| 732 | <SettingsSection title="Legacy"> | 161 | icon="text.bubble" |
| 733 | <SettingsSwitchRow | 162 | label="New threads" |
| 734 | icon="sidebar.left" | 163 | target="SettingsEnvironmentNewThreads" |
| 735 | label="Legacy Thread List" | 164 | disabled={noServerTargets} |
| 736 | value={!threadListV2Enabled} | 165 | /> |
| 737 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} | 166 | <SettingsRow |
| 167 | icon="arrow.triangle.branch" | ||
| 168 | label="Source control" | ||
| 169 | target="SettingsEnvironmentSourceControl" | ||
| 170 | disabled={noServerTargets} | ||
| 738 | /> | 171 | /> |
| 739 | <SettingsSwitchRow | 172 | <SettingsRow |
| 740 | icon="hammer" | 173 | icon="text.alignleft" |
| 741 | label="Plan Mode" | 174 | label="Agent behavior" |
| 742 | value={planModeEnabled} | 175 | target="SettingsEnvironmentAgentBehavior" |
| 743 | onValueChange={(value) => savePreferences({ planModeEnabled: value })} | 176 | disabled={noServerTargets} |
| 177 | /> | ||
| 178 | <SettingsRow | ||
| 179 | icon="arrow.clockwise" | ||
| 180 | label="Maintenance" | ||
| 181 | target="SettingsEnvironmentMaintenance" | ||
| 182 | disabled={noServerTargets} | ||
| 744 | /> | 183 | /> |
| 745 | </SettingsSection> | 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"; | 186 | <SettingsSection title="App"> |
| 760 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | 187 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 761 | // missing variant never mislabels a production build as development. | 188 | <SettingsRow icon="info.circle" label="About T3 Code" target="SettingsAbout" /> |
| 762 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | 189 | </SettingsSection> |
| 763 | const variantLabel = variant === "production" ? "" : capitalize(variant); | 190 | </> |
| 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> | ||
| 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,4 +1,4 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; | 4 | import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; |
| ... | @@ -75,4 +75,18 @@ describe("auto-settle settings sync", () => { | ... | @@ -75,4 +75,18 @@ describe("auto-settle settings sync", () => { |
| 75 | 75 | ||
| 76 | expect(plan.mismatches).toEqual([]); | 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,4 +1,4 @@ |
| 1 | import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ProjectId, ServerSettings } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export type AutoSettleSettings = Pick< | 3 | export type AutoSettleSettings = Pick< |
| 4 | ServerSettings, | 4 | ServerSettings, |
| ... | @@ -7,13 +7,18 @@ export type AutoSettleSettings = Pick< | ... | @@ -7,13 +7,18 @@ export type AutoSettleSettings = Pick< |
| 7 | 7 | ||
| 8 | interface AutoSettleSyncTarget { | 8 | interface AutoSettleSyncTarget { |
| 9 | readonly environmentId: EnvironmentId; | 9 | readonly environmentId: EnvironmentId; |
| 10 | readonly projectId?: ProjectId | null; | ||
| 10 | readonly label: string; | 11 | readonly label: string; |
| 11 | readonly settings: AutoSettleSettings | null; | 12 | readonly settings: AutoSettleSettings | null; |
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ | 15 | /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ |
| 15 | export function planAutoSettleSettingsSync( | 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 | targets: readonly AutoSettleSyncTarget[], | 22 | targets: readonly AutoSettleSyncTarget[], |
| 18 | ) { | 23 | ) { |
| 19 | const patch: AutoSettleSettings = { | 24 | const patch: AutoSettleSettings = { |
| ... | @@ -22,7 +27,8 @@ export function planAutoSettleSettingsSync( | ... | @@ -22,7 +27,8 @@ export function planAutoSettleSettingsSync( |
| 22 | }; | 27 | }; |
| 23 | const mismatches = targets.filter( | 28 | const mismatches = targets.filter( |
| 24 | (target) => | 29 | (target) => |
| 25 | target.environmentId !== reference.environmentId && | 30 | (target.environmentId !== reference.environmentId || |
| 31 | target.projectId !== reference.projectId) && | ||
| 26 | target.settings !== null && | 32 | target.settings !== null && |
| 27 | (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || | 33 | (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || |
| 28 | target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), | 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,6 +10,7 @@ import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; |
| 10 | 10 | ||
| 11 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | 11 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 12 | const adjust = (amount: number) => { | 12 | const adjust = (amount: number) => { |
| 13 | if (props.disabled) return; | ||
| 13 | const next = Math.max( | 14 | const next = Math.max( |
| 14 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | 15 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 15 | Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), | 16 | Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), |
| ... | @@ -28,7 +29,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | ... | @@ -28,7 +29,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 28 | <MaterialIconButton | 29 | <MaterialIconButton |
| 29 | icon="minus" | 30 | icon="minus" |
| 30 | accessibilityLabel="Decrease days before auto-settle" | 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 | onPress={() => adjust(-1)} | 33 | onPress={() => adjust(-1)} |
| 33 | /> | 34 | /> |
| 34 | <AppText | 35 | <AppText |
| ... | @@ -42,7 +43,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | ... | @@ -42,7 +43,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 42 | <MaterialIconButton | 43 | <MaterialIconButton |
| 43 | icon="plus" | 44 | icon="plus" |
| 44 | accessibilityLabel="Increase days before auto-settle" | 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 | onPress={() => adjust(1)} | 47 | onPress={() => adjust(1)} |
| 47 | /> | 48 | /> |
| 48 | </View> | 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,6 +2,7 @@ import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/sw |
| 2 | import { | 2 | import { |
| 3 | accessibilityLabel, | 3 | accessibilityLabel, |
| 4 | buttonStyle, | 4 | buttonStyle, |
| 5 | disabled, | ||
| 5 | font, | 6 | font, |
| 6 | foregroundStyle, | 7 | foregroundStyle, |
| 7 | frame, | 8 | frame, |
| ... | @@ -34,11 +35,13 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | ... | @@ -34,11 +35,13 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 34 | <Popover.Trigger> | 35 | <Popover.Trigger> |
| 35 | <Button | 36 | <Button |
| 36 | onPress={() => { | 37 | onPress={() => { |
| 38 | if (props.disabled) return; | ||
| 37 | setDraft(props.value); | 39 | setDraft(props.value); |
| 38 | setOpen(true); | 40 | setOpen(true); |
| 39 | }} | 41 | }} |
| 40 | modifiers={[ | 42 | modifiers={[ |
| 41 | buttonStyle("bordered"), | 43 | buttonStyle("bordered"), |
| 44 | disabled(props.disabled), | ||
| 42 | accessibilityLabel(`Days before auto-settle: ${props.value}`), | 45 | accessibilityLabel(`Days before auto-settle: ${props.value}`), |
| 43 | frame({ minWidth: 64, minHeight: 44 }), | 46 | frame({ minWidth: 64, minHeight: 44 }), |
| 44 | foregroundStyle(colors["--color-primary"]), | 47 | foregroundStyle(colors["--color-primary"]), |
| ... | @@ -81,7 +84,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | ... | @@ -81,7 +84,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 81 | label="Done" | 84 | label="Done" |
| 82 | onPress={() => { | 85 | onPress={() => { |
| 83 | setOpen(false); | 86 | setOpen(false); |
| 84 | if (draft !== props.value) props.onValueChange(draft); | 87 | if (!props.disabled && draft !== props.value) props.onValueChange(draft); |
| 85 | }} | 88 | }} |
| 86 | modifiers={[foregroundStyle(colors["--color-primary"])]} | 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,11 +9,16 @@ import { AppTextInput } from "../../../components/AppText"; |
| 9 | export interface AutoSettleDaysFieldProps { | 9 | export interface AutoSettleDaysFieldProps { |
| 10 | readonly value: number; | 10 | readonly value: number; |
| 11 | readonly onValueChange: (value: number) => void; | 11 | readonly onValueChange: (value: number) => void; |
| 12 | readonly disabled?: boolean; | ||
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | 15 | export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 15 | const [draft, setDraft] = useState<string | null>(null); | 16 | const [draft, setDraft] = useState<string | null>(null); |
| 16 | const commit = () => { | 17 | const commit = () => { |
| 18 | if (props.disabled) { | ||
| 19 | setDraft(null); | ||
| 20 | return; | ||
| 21 | } | ||
| 17 | const text = (draft ?? "").trim(); | 22 | const text = (draft ?? "").trim(); |
| 18 | setDraft(null); | 23 | setDraft(null); |
| 19 | // Validate the whole input; decimals and trailing text must not become whole days. | 24 | // Validate the whole input; decimals and trailing text must not become whole days. |
| ... | @@ -37,6 +42,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { | ... | @@ -37,6 +42,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { |
| 37 | onBlur={commit} | 42 | onBlur={commit} |
| 38 | onSubmitEditing={commit} | 43 | onSubmitEditing={commit} |
| 39 | accessibilityLabel="Days before auto-settle" | 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,6 +5,7 @@ import { AppText as Text } from "../../../components/AppText"; |
| 5 | 5 | ||
| 6 | export function SettingsSection(props: { | 6 | export function SettingsSection(props: { |
| 7 | readonly title?: string; | 7 | readonly title?: string; |
| 8 | readonly trailing?: ReactNode; | ||
| 8 | readonly children: ReactNode; | 9 | readonly children: ReactNode; |
| 9 | /** Force the grouped card background; Android otherwise lists options flat. */ | 10 | /** Force the grouped card background; Android otherwise lists options flat. */ |
| 10 | readonly card?: boolean; | 11 | readonly card?: boolean; |
| ... | @@ -12,15 +13,18 @@ export function SettingsSection(props: { | ... | @@ -12,15 +13,18 @@ export function SettingsSection(props: { |
| 12 | return ( | 13 | return ( |
| 13 | <View className="gap-2"> | 14 | <View className="gap-2"> |
| 14 | {props.title ? ( | 15 | {props.title ? ( |
| 15 | <Text | 16 | <View className="flex-row items-center justify-between gap-3"> |
| 16 | className={ | 17 | <Text |
| 17 | Platform.OS === "android" | 18 | className={ |
| 18 | ? "px-4 text-sm font-t3-medium text-primary" | 19 | Platform.OS === "android" |
| 19 | : "px-2 text-sm font-t3-medium text-foreground-muted" | 20 | ? "px-4 text-sm font-t3-medium text-primary" |
| 20 | } | 21 | : "px-2 text-sm font-t3-medium text-foreground-muted" |
| 21 | > | 22 | } |
| 22 | {props.title} | 23 | > |
| 23 | </Text> | 24 | {props.title} |
| 25 | </Text> | ||
| 26 | {props.trailing} | ||
| 27 | </View> | ||
| 24 | ) : null} | 28 | ) : null} |
| 25 | <View | 29 | <View |
| 26 | className={ | 30 | className={ |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+9| ... | @@ -1,7 +1,16 @@ | ... | @@ -1,7 +1,16 @@ |
| 1 | export type SettingsSheetTarget = | 1 | export type SettingsSheetTarget = |
| 2 | | "SettingsEnvironments" | 2 | | "SettingsEnvironments" |
| 3 | | "SettingsNotifications" | ||
| 4 | | "SettingsThreads" | ||
| 5 | | "SettingsAbout" | ||
| 3 | | "SettingsArchive" | 6 | | "SettingsArchive" |
| 4 | | "SettingsAppearance" | 7 | | "SettingsAppearance" |
| 8 | | "SettingsOrganization" | ||
| 9 | | "SettingsProjectOverview" | ||
| 10 | | "SettingsEnvironmentNewThreads" | ||
| 11 | | "SettingsEnvironmentSourceControl" | ||
| 12 | | "SettingsEnvironmentAgentBehavior" | ||
| 13 | | "SettingsEnvironmentMaintenance" | ||
| 5 | | "SettingsKeyboard" | 14 | | "SettingsKeyboard" |
| 6 | | "SettingsProjectGrouping" | 15 | | "SettingsProjectGrouping" |
| 7 | | "SettingsClientStorage" | 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,8 +1,9 @@ |
| 1 | # Appearance and themes | 1 | # Appearance and themes |
| 2 | 2 | ||
| 3 | Open **Settings → Appearance** to choose a theme and follow the system appearance or stay in light | 3 | On web and desktop, open **Settings → Appearance** to choose a theme and follow the system |
| 4 | or dark mode. To use different themes for light and dark mode, select the corresponding preview | 4 | appearance or stay in light or dark mode. To use different themes for light and dark mode, select |
| 5 | within each theme. Appearance preferences are saved separately on each device or browser. | 5 | the corresponding preview within each theme. Appearance preferences are saved separately on each |
| 6 | device or browser. | ||
| 6 | 7 | ||
| 7 | On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. | 8 | On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat. |
| 8 | Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly. | 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,8 +11,8 @@ Use **Change appearance** in the command palette to choose System, Light, or Dar |
| 10 | the theme. **Cmd+Option+Shift+A** on macOS or **Ctrl+Alt+Shift+A** on Windows/Linux cycles through | 11 | the theme. **Cmd+Option+Shift+A** on macOS or **Ctrl+Alt+Shift+A** on Windows/Linux cycles through |
| 11 | those modes. Customize these shortcuts under **Settings → Keybindings**. | 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 | On mobile, open **Settings → Appearance**. Mobile has its own themes and text, |
| 14 | themes or defaults. | 15 | code, and terminal preferences. It does not follow environment themes or defaults. |
| 15 | 16 | ||
| 16 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from | 17 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from |
| 17 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You | 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,6 +1,6 @@ |
| 1 | # Mobile notifications | 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 | 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. | 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,7 +5,7 @@ notices: |
| 5 | 5 | ||
| 6 | - On web and desktop, open **Settings → General**, find **Open source licenses** under **About**, | 6 | - On web and desktop, open **Settings → General**, find **Open source licenses** under **About**, |
| 7 | and select **View licenses**. | 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 | The page lists the version when available, license identifier, and the parts of T3 Code that include | 10 | The page lists the version when available, license identifier, and the parts of T3 Code that include |
| 11 | or use the item. This also covers optional device tools that T3 Code installs on demand instead of | 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,6 +1,6 @@ |
| 1 | # Settings and project overrides | 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 | at **All environments** and **All projects** and stay selected as you move between categories or | 4 | at **All environments** and **All projects** and stay selected as you move between categories or |
| 5 | search for a setting. | 5 | search for a setting. |
| 6 | 6 | ||
| ... | @@ -26,6 +26,17 @@ again. | ... | @@ -26,6 +26,17 @@ again. |
| 26 | Providers and diagnostics are per machine: they show one environment at a time, the primary | 26 | Providers and diagnostics are per machine: they show one environment at a time, the primary |
| 27 | one until you pick another. Every other setting fans out to the selection. | 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 | ## Defaults and inheritance | 40 | ## Defaults and inheritance |
| 30 | 41 | ||
| 31 | General contains the model and workspace for new threads. Integrations controls agent browser | 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,6 +94,7 @@ from the icon palette, derived from the saved project name. For example, `Nebula |
| 83 | 94 | ||
| 84 | In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date | 95 | In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date |
| 85 | with its configured upstream. Choose an environment to set the default or a project to override it. | 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 | T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, | 99 | T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, |
| 88 | or local commits. It skips checkouts on another branch or without an upstream. If a checkout has | 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,9 +93,9 @@ prevent automatic settlement. An open pull request does not prevent inactivity |
| 93 | settlement, but an old closed or merged pull request does not settle work you | 93 | settlement, but an old closed or merged pull request does not settle work you |
| 94 | resumed after it closed. | 94 | resumed after it closed. |
| 95 | 95 | ||
| 96 | Change these rules in **Settings → General**. They continue to run when your apps | 96 | Change these rules in **Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile. |
| 97 | are closed. On web and desktop, choose an environment at the top to change only | 97 | They continue to run when your apps are closed. On web and desktop, choose an environment at the |
| 98 | its rules, or **All environments** to update connected environments together. | 98 | top to change only its rules, or **All environments** to update connected environments together. |
| 99 | Mixed values show where the selected environments disagree. Mobile applies these | 99 | Mixed values show where the selected environments disagree. Mobile applies these |
| 100 | rules to connected environments that support shared settings. Offline environments | 100 | rules to connected environments that support shared settings. Offline environments |
| 101 | and older servers keep their previous values. Changing a rule does not reopen | 101 | and older servers keep their previous values. Changing a rule does not reopen |