From c25c3e0ee678ef62245cb5eef20eeafb9a86d9d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 10:56:01 -0700 Subject: [PATCH] feat(mobile): make Settings easier to navigate and scope (#12272) --- apps/mobile/src/Stack.tsx | 65 +- .../settings/SettingsAboutRouteScreen.tsx | 149 +++ .../SettingsNotificationsRouteScreen.tsx | 499 ++++++++++ .../SettingsProjectGroupingRouteScreen.tsx | 4 +- .../SettingsProjectOverviewRouteScreen.tsx | 174 ++++ .../features/settings/SettingsRouteScreen.tsx | 930 +++--------------- .../SettingsServerControlsRouteScreen.tsx | 474 +++++++++ .../settings/SettingsThreadsRouteScreen.tsx | 266 +++++ .../settings/autoSettleSettingsSync.test.ts | 16 +- .../settings/autoSettleSettingsSync.ts | 12 +- .../AutoSettleDaysField.android.tsx | 5 +- .../components/AutoSettleDaysField.ios.tsx | 5 +- .../components/AutoSettleDaysField.tsx | 6 + .../SettingsEnvironmentFilterHeader.tsx | 187 ++++ .../SettingsProjectOverridesSection.tsx | 37 + .../settings/components/SettingsSection.tsx | 22 +- .../components/settings-sheet-targets.ts | 9 + .../settings-environment-filter.logic.ts | 15 + .../settings-environment-filter.test.ts | 30 + .../settings/settings-environment-filter.tsx | 100 ++ .../settings/settings-scoped-server.test.ts | 94 ++ .../settings/settings-scoped-server.ts | 106 ++ docs/user/appearance.md | 11 +- docs/user/mobile-notifications.md | 2 +- docs/user/open-source-licenses.md | 2 +- docs/user/project-settings.md | 14 +- docs/user/thread-sidebar.md | 6 +- 27 files changed, 2403 insertions(+), 837 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx create mode 100644 apps/mobile/src/features/settings/settings-environment-filter.logic.ts create mode 100644 apps/mobile/src/features/settings/settings-environment-filter.test.ts create mode 100644 apps/mobile/src/features/settings/settings-environment-filter.tsx create mode 100644 apps/mobile/src/features/settings/settings-scoped-server.test.ts create mode 100644 apps/mobile/src/features/settings/settings-scoped-server.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 71efbb11cff210f7b38ca3e38f75d95e4b5e7b8f..c750e366c1d3c542952d74d0ef5681fbccae21af 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -59,6 +59,12 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { + SettingsEnvironmentAgentBehaviorRouteScreen, + SettingsEnvironmentMaintenanceRouteScreen, + SettingsEnvironmentNewThreadsRouteScreen, + SettingsEnvironmentSourceControlRouteScreen, +} from "./features/settings/SettingsServerControlsRouteScreen"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { @@ -66,9 +72,14 @@ import { SettingsOpenSourceLicensesRouteScreen, } from "./features/settings/SettingsOpenSourceLicensesRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { SettingsProjectOverviewRouteScreen } from "./features/settings/SettingsProjectOverviewRouteScreen"; import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; +import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen"; +import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; +import { SettingsThreadsRouteScreen } from "./features/settings/SettingsThreadsRouteScreen"; +import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -168,6 +179,41 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Environments", }, }), + SettingsEnvironmentNewThreads: createNativeStackScreen({ + screen: SettingsEnvironmentNewThreadsRouteScreen, + linking: "new-threads", + options: { title: "New threads" }, + }), + SettingsEnvironmentSourceControl: createNativeStackScreen({ + screen: SettingsEnvironmentSourceControlRouteScreen, + linking: "source-control", + options: { title: "Source control" }, + }), + SettingsEnvironmentAgentBehavior: createNativeStackScreen({ + screen: SettingsEnvironmentAgentBehaviorRouteScreen, + linking: "agent-behavior", + options: { title: "Agent behavior" }, + }), + SettingsEnvironmentMaintenance: createNativeStackScreen({ + screen: SettingsEnvironmentMaintenanceRouteScreen, + linking: "maintenance", + options: { title: "Maintenance" }, + }), + SettingsNotifications: createNativeStackScreen({ + screen: SettingsNotificationsRouteScreen, + linking: "notifications", + options: { title: "Notifications" }, + }), + SettingsThreads: createNativeStackScreen({ + screen: SettingsThreadsRouteScreen, + linking: "thread-preferences", + options: { title: "Thread behavior" }, + }), + SettingsAbout: createNativeStackScreen({ + screen: SettingsAboutRouteScreen, + linking: "about", + options: { title: "About T3 Code" }, + }), SettingsEnvironmentNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, linking: "environment-new", @@ -193,9 +239,21 @@ const SettingsContentStack = createNativeStackNavigator({ screen: SettingsProjectGroupingRouteScreen, linking: "project-grouping", options: { - title: "Project Grouping", + title: "Organization", }, }), + SettingsOrganization: createNativeStackScreen({ + screen: SettingsProjectGroupingRouteScreen, + linking: "organization", + options: { + title: "Organization", + }, + }), + SettingsProjectOverview: createNativeStackScreen({ + screen: SettingsProjectOverviewRouteScreen, + linking: "project", + options: { title: "Project overview" }, + }), SettingsKeyboard: createNativeStackScreen({ screen: SettingsKeyboardRouteScreen, linking: "keyboard", @@ -257,6 +315,9 @@ const SettingsSheetStack = createNativeStackNavigator({ SettingsContent: createNativeStackScreen({ screen: SettingsContentStack, linking: "", + layout: ({ children }) => ( + {children} + ), }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, @@ -632,7 +693,7 @@ export const RootStack = createNativeStackNavigator({ ? { presentation: "card" as const } : { ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.7, 0.92], + sheetAllowedDetents: [0.92], sheetGrabberVisible: true, }), }, diff --git a/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bbb70b236bead9922bfdac2d04f16775d4b45d91 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx @@ -0,0 +1,149 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import Constants from "expo-constants"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { + type AppUpdateCheckState, + isAppUpdateCheckAvailable, + registerHiddenUpdateTap, + runAppUpdateCheck, +} from "../updates/app-updates"; +import { SettingsRow } from "./components/SettingsRow"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsScreen } from "./components/SettingsScreen"; + +export function SettingsAboutRouteScreen() { + const insets = useSafeAreaInsets(); + + return ( + + + + + + ); +} + +function AppSettingsSection() { + const [updateState, setUpdateState] = useState("idle"); + const updateInFlight = useRef(false); + const hiddenUpdateTapCount = useRef(0); + + const version = Constants.expoConfig?.version ?? "0.0.0"; + // Fall back to "production" to match resolveAppVariant in app.config.ts, so a + // missing variant never mislabels a production build as development. + const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; + const variantLabel = variant === "production" ? "" : capitalize(variant); + const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + const updateCheckAvailable = isAppUpdateCheckAvailable(); + const busy = + updateState === "checking" || updateState === "downloading" || updateState === "restarting"; + + // "Up to date" is a transient acknowledgement, not a state worth persisting — + // return the version row to its normal, deliberately quiet state. + useEffect(() => { + if (updateState !== "current") return; + const timer = setTimeout(() => setUpdateState("idle"), 3000); + return () => clearTimeout(timer); + }, [updateState]); + + const checkForUpdate = useCallback(async () => { + // `disabled={busy}` only takes effect on the next render, so two taps in the + // same frame would both get through. The ref closes that window. + if (updateInFlight.current) return; + updateInFlight.current = true; + try { + // The user asked for this restart by tapping the version row, so it may + // apply immediately instead of prompting. + await runAppUpdateCheck({ + applyMode: "immediate", + onFailure: (message) => Alert.alert("Update failed", message), + onStateChange: setUpdateState, + }); + } finally { + updateInFlight.current = false; + } + }, []); + + const handleVersionPress = useCallback(() => { + if (!updateCheckAvailable || updateInFlight.current) return; + const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); + hiddenUpdateTapCount.current = tap.nextCount; + if (tap.shouldCheck) { + void checkForUpdate(); + } + }, [checkForUpdate, updateCheckAvailable]); + + const statusLabel = + updateState === "checking" + ? "Checking…" + : updateState === "downloading" + ? "Downloading…" + : // "ready" appears only when this check joined an in-flight background-mode + // check; that download installs at the next backgrounding. + updateState === "ready" + ? "Update ready" + : updateState === "restarting" + ? "Restarting…" + : updateState === "current" + ? "Up to date" + : null; + + const versionRow = ( + + + Version + + {versionLabel} + {statusLabel ? ( + {statusLabel} + ) : null} + + + ); + + return ( + + + + + + {updateCheckAvailable ? ( + + {versionRow} + + ) : ( + versionRow + )} + + ); +} + +function capitalize(value: string): string { + return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8809c429c824bcd6003170a42fd7a3991b0284f2 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -0,0 +1,499 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { useAuth } from "@clerk/expo"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as Notifications from "expo-notifications"; +import { useNavigation } from "@react-navigation/native"; +import * as Effect from "effect/Effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { Alert, AppState, Linking, Platform } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { + isAtomCommandInterrupted, + reportAtomCommandResult, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; +import { + openAndroidLiveUpdateSettings, + supportsAndroidLiveUpdateSettings, +} from "../agent-awareness/androidNotifications"; +import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; +import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; +import { + getAgentAwarenessRegistrationStatus, + refreshAgentAwarenessRegistration, + subscribeAgentAwarenessRegistrationStatus, +} from "../agent-awareness/remoteRegistration"; +import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; +import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; +import { runtime } from "../../lib/runtime"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { SettingsRow } from "./components/SettingsRow"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; + +type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; +type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; + +// Reflects whether the relay actually accepted this device's registration. +// The notification and Live Activity switches are gated on this so they can +// never read as enabled when the device cannot receive anything (e.g. the +// registration request timed out). +function useDeviceRegistered(): boolean { + const status = useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + getAgentAwarenessRegistrationStatus, + () => "unknown" as const, + ); + return status === "registered"; +} + +export function SettingsNotificationsRouteScreen() { + if (!hasCloudPublicConfig()) { + return ( + + + + Notifications require T3 Connect in this app build. + + + + ); + } + + return ; +} + +function ConfiguredSettingsNotificationsRouteScreen() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); + const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); + const agentAwarenessSubtitle = + Platform.OS === "android" && !agentAwarenessPushAvailable + ? "Install a newer app build to enable notifications" + : agentAwarenessPlatform.subtitle; + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { savedConnectionsById } = useSavedRemoteConnections(); + const [notificationStatus, setNotificationStatus] = useState("checking"); + const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); + const liveActivityWriteInFlight = useRef(false); + const deviceRegistered = useDeviceRegistered(); + const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.liveActivitiesEnabled !== false + : true; + const canClearLiveActivitiesPreference = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.liveActivitiesEnabled !== false; + + const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); + const environmentCount = connections.length; + + const refreshNotifications = useCallback(async () => { + if (Platform.OS !== "ios" && Platform.OS !== "android") { + setNotificationStatus("unsupported"); + return; + } + const result = await settlePromise(() => Notifications.getPermissionsAsync()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "notification permission refresh" }); + setNotificationStatus("disabled"); + return; + } + setNotificationStatus(result.value.granted ? "enabled" : "disabled"); + }, []); + + useEffect(() => { + void refreshNotifications(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void refreshNotifications(); + }); + return () => subscription.remove(); + }, [refreshNotifications]); + + useEffect(() => { + if (!isLoaded) { + setLiveActivityStatus("checking"); + return; + } + if (!isSignedIn) { + setLiveActivityStatus("signed-out"); + return; + } + if (!AsyncResult.isSuccess(preferencesResult)) { + if (AsyncResult.isFailure(preferencesResult)) { + reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); + setLiveActivityStatus("enabled"); + } else { + setLiveActivityStatus("checking"); + } + return; + } + setLiveActivityStatus( + preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", + ); + }, [isLoaded, isSignedIn, preferencesResult]); + + const requestNotifications = useCallback(async () => { + const result = await settleAsyncResult(() => + runtime.runPromiseExit( + requestAgentNotificationPermission.pipe( + Effect.tap((permission) => + permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, + ), + ), + ), + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Notifications unavailable", + error instanceof Error ? error.message : "Could not request notification permission.", + ); + } + return; + } + if (result.value.type === "granted") { + setNotificationStatus("enabled"); + // Permission alone is not enough: the switch stays off until the relay + // registration succeeds, so tell the user the truth about which happened. + if (getAgentAwarenessRegistrationStatus() === "registered") { + Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); + } else { + Alert.alert( + "Couldn't finish enabling notifications", + "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", + ); + } + return; + } + if (result.value.type === "unsupported") { + setNotificationStatus("unsupported"); + Alert.alert( + "Notifications unavailable", + "Agent notifications are unavailable on this platform.", + ); + return; + } + setNotificationStatus("disabled"); + if (result.value.canAskAgain) { + Alert.alert("Notifications disabled", "Notifications were not enabled."); + return; + } + Alert.alert( + "Notifications disabled", + "Notifications were denied for this app. Open Settings to enable them.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + }, []); + + const promptSignIn = useCallback(() => { + Alert.alert( + "Sign in to T3 Connect", + "Live Activity updates require T3 Connect so relay can deliver updates to this device.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Continue", + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), + }, + ], + ); + }, [navigation]); + + const linkEnvironments = useCallback(async () => { + if (!isSignedIn) { + promptSignIn(); + return; + } + + setLiveActivityStatus("linking"); + if (Platform.OS === "android") { + const permission = await settleAsyncResult(() => + runtime.runPromiseExit(requestAgentNotificationPermission), + ); + if (permission._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(permission); + Alert.alert( + "Ongoing activity unavailable", + error instanceof Error ? error.message : "Could not enable agent notifications.", + ); + return; + } + if (permission.value.type !== "granted") { + setLiveActivityStatus("disabled"); + Alert.alert( + "Notification permission needed", + "Enable notifications in system Settings to show ongoing agent activity.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + return; + } + setNotificationStatus("enabled"); + } + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + setLiveActivityStatus("disabled"); + const error = squashAtomCommandFailure(tokenResult); + Alert.alert( + Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", + ); + return; + } + if (!tokenResult.value) { + promptSignIn(); + setLiveActivityStatus("signed-out"); + return; + } + + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( + setLiveActivityUpdatesEnabled({ + enabled: true, + previousEnabled: liveActivitiesPreferenceEnabled, + clerkToken: tokenResult.value, + connections, + }), + ), + ); + if (updateResult._tag === "Failure") { + setLiveActivityStatus("disabled"); + if (!isAtomCommandInterrupted(updateResult)) { + const error = squashAtomCommandFailure(updateResult); + Alert.alert( + Platform.OS === "android" + ? "Ongoing activity unavailable" + : "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable agent activity updates.", + ); + } + return; + } + + savePreferences({ liveActivitiesEnabled: true }); + refreshManagedRelayEnvironments(); + setLiveActivityStatus("enabled"); + // The environment link can succeed while this device's own registration + // (the push-to-start token the relay needs) has not — don't claim Live + // Activities are live until the device is actually registered. + if (getAgentAwarenessRegistrationStatus() === "registered") { + Alert.alert( + Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", + environmentCount > 0 + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` + : "Agent activity updates are enabled. Add an environment to start receiving updates.", + ); + } else { + Alert.alert( + "Couldn't finish enabling activity updates", + "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", + ); + } + }, [ + connections, + environmentCount, + getToken, + isSignedIn, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ]); + + const handleDeviceNotificationsChange = useCallback( + (enabled: boolean) => { + if (enabled) { + if (!isSignedIn) { + promptSignIn(); + return; + } + void requestNotifications(); + return; + } + + Alert.alert( + "Disable notifications", + "Open system Settings to disable notifications for T3 Code.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + }, + [isSignedIn, promptSignIn, requestNotifications], + ); + + const handleLiveActivitiesChange = useCallback( + (enabled: boolean) => { + if (liveActivityWriteInFlight.current) return; + if (!enabled) { + liveActivityWriteInFlight.current = true; + setLiveActivityStatus("linking"); + void (async () => { + try { + let token: string | null = null; + if (isSignedIn) { + const tokenResult = await settlePromise(() => + getToken(resolveRelayClerkTokenOptions()), + ); + if (tokenResult._tag === "Failure") { + setLiveActivityStatus("enabled"); + reportAtomCommandResult(tokenResult, { + label: "live activity disable token lookup", + }); + return; + } + token = tokenResult.value; + } + + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( + setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: liveActivitiesPreferenceEnabled, + clerkToken: token, + connections, + }), + ), + ); + if (updateResult._tag === "Failure") { + setLiveActivityStatus(isSignedIn ? "enabled" : "signed-out"); + reportAtomCommandResult(updateResult, { + label: "live activity disable", + }); + return; + } + savePreferences({ liveActivitiesEnabled: false }); + refreshManagedRelayEnvironments(); + setLiveActivityStatus("disabled"); + } finally { + liveActivityWriteInFlight.current = false; + } + })(); + return; + } + + if (!isSignedIn) { + promptSignIn(); + return; + } + + liveActivityWriteInFlight.current = true; + void linkEnvironments().finally(() => { + liveActivityWriteInFlight.current = false; + }); + }, + [ + connections, + getToken, + isSignedIn, + linkEnvironments, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ], + ); + + return ( + + + + + + {liveActivityStatus === "signed-out" && canClearLiveActivitiesPreference ? ( + handleLiveActivitiesChange(false)} + /> + ) : null} + {supportsAndroidLiveUpdateSettings() ? ( + { + void openAndroidLiveUpdateSettings().catch(() => { + Alert.alert( + "Couldn't open Settings", + "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", + ); + }); + }} + /> + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 5eb815098aaaf769a17997d2d53951749a183078..fb17ed64edc9416c8b635de668f05178b43dc939 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -47,7 +47,7 @@ export function SettingsProjectGroupingRouteScreen() { : null; return ( - + - + {GROUPING_OPTIONS.map((option, index) => ( entry.key === selectedProjectKey); + const selectedEnvironmentIds = new Set(selectedTargets.map((entry) => entry.environmentId)); + const members = + group?.members + .map((entry) => entry.project) + .filter((project) => selectedEnvironmentIds.has(project.environmentId)) ?? []; + + return ( + <> + + }> + + {members.length === 0 ? ( + + This project has no checkout on the selected connected environments. Change the filter + above. + + ) : ( + member.id).join(",")}`} + members={members} + environments={selectedTargets} + /> + )} + + + + ); +} + +function ProjectOverviewContent(props: { + readonly members: readonly EnvironmentProject[]; + readonly environments: readonly SettingsTarget[]; +}) { + const representative = props.members[0]!; + const displayName = deriveProjectGroupLabel({ representative, members: props.members }); + const [draftName, setDraftName] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const updateProject = useAtomCommand(projectEnvironment.update, { + label: "project name update", + reportFailure: true, + }); + const nextName = (draftName ?? displayName).trim(); + const canSave = !isSaving && nextName.length > 0 && nextName !== displayName; + + const saveName = () => { + if (!canSave) return; + setIsSaving(true); + void (async () => { + try { + const results = await Promise.all( + props.members.map((member) => + updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, title: nextName }, + }), + ), + ); + if (results.every((result) => result._tag !== "Failure")) setDraftName(null); + } finally { + setIsSaving(false); + } + })(); + }; + + return ( + <> + + + + + {displayName} + + + {props.members.length === 1 ? "1 checkout" : `${props.members.length} checkouts`} + + + + + + + Name + + + {canSave ? ( + + Save + + ) : null} + + + + + + {props.members.map((member, index) => { + const environment = props.environments.find( + (entry) => entry.environmentId === member.environmentId, + ); + return ( + + + {environment?.label ?? "Environment"} + + {environment?.displayUrl ? ( + + {environment.displayUrl} + + ) : null} + + {member.workspaceRoot} + + + ); + })} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d32dcb3e6828da4f0a7819a8c69cee3739c60642..c8a3b92abedee8fe36e4688a494c452f5bbede0e 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,83 +1,23 @@ -import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAuth, useUser } from "@clerk/expo"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import Constants from "expo-constants"; -import * as Notifications from "expo-notifications"; import { useNavigation } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { SymbolView } from "../../components/AppSymbol"; -import * as Effect from "effect/Effect"; -import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, Pressable, View } from "react-native"; +import { Platform, View } from "react-native"; +import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - isAtomCommandInterrupted, - reportAtomCommandResult, - settleAsyncResult, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import { AppText as Text } from "../../components/AppText"; -import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; -import { - openAndroidLiveUpdateSettings, - supportsAndroidLiveUpdateSettings, -} from "../agent-awareness/androidNotifications"; -import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; -import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; -import { - getAgentAwarenessRegistrationStatus, - refreshAgentAwarenessRegistration, - subscribeAgentAwarenessRegistrationStatus, -} from "../agent-awareness/remoteRegistration"; -import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; -import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; -import { runtime } from "../../lib/runtime"; -import { cn } from "../../lib/cn"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useEnvironments } from "../../state/environments"; -import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; -import { - type AppUpdateCheckState, - isAppUpdateCheckAvailable, - registerHiddenUpdateTap, - runAppUpdateCheck, -} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; -import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; -import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; -import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; - -type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; -type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; - -// Reflects whether the relay actually accepted this device's registration. -// The notification and Live Activity switches are gated on this so they can -// never read as enabled when the device cannot receive anything (e.g. the -// registration request timed out). -function useDeviceRegistered(): boolean { - const status = useSyncExternalStore( - subscribeAgentAwarenessRegistrationStatus, - getAgentAwarenessRegistrationStatus, - () => "unknown" as const, - ); - return status === "registered"; -} +import { + AndroidSettingsEnvironmentFilter, + SettingsEnvironmentFilterHeader, +} from "./components/SettingsEnvironmentFilterHeader"; +import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; export function SettingsRouteScreen() { - const navigation = useNavigation(); const content = hasCloudPublicConfig() ? ( ) : ( @@ -87,27 +27,11 @@ export function SettingsRouteScreen() { return ( <> - {Platform.OS !== "android" ? ( - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Close settings", - icon: { name: "xmark", type: "sfSymbol" } as const, - identifier: "settings-close", - label: "", - onPress: () => navigation.goBack(), - type: "button", - }), - ] - : undefined, - }} - /> - ) : null} + {Platform.OS === "android" ? ( - {content} + }> + {content} + ) : ( content )} @@ -115,379 +39,55 @@ export function SettingsRouteScreen() { ); } -function LocalSettingsRouteScreen() { - const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useSavedRemoteConnections(); - const environmentCount = Object.keys(savedConnectionsById).length; - - return ( - - - - - - - - - - - - - - - - - - - - ); -} - function ConfiguredSettingsRouteScreen() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); - const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); - const agentAwarenessSubtitle = - Platform.OS === "android" && !agentAwarenessPushAvailable - ? "Install a newer app build to enable notifications" - : agentAwarenessPlatform.subtitle; const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); - const [notificationStatus, setNotificationStatus] = useState("checking"); - const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); - const deviceRegistered = useDeviceRegistered(); - const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.liveActivitiesEnabled !== false - : true; + const accountLabel = !isLoaded + ? "Checking" + : !isSignedIn + ? "Sign in" + : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); - const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); - const environmentCount = connections.length; - const accountLabel = useMemo(() => { - if (!isLoaded) return "Checking"; - if (!isSignedIn) return "Sign in"; - return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; - }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); - - const refreshNotifications = useCallback(async () => { - if (Platform.OS !== "ios" && Platform.OS !== "android") { - setNotificationStatus("unsupported"); - return; - } - const result = await settlePromise(() => Notifications.getPermissionsAsync()); - if (result._tag === "Failure") { - reportAtomCommandResult(result, { label: "notification permission refresh" }); - setNotificationStatus("disabled"); - return; - } - setNotificationStatus(result.value.granted ? "enabled" : "disabled"); - }, []); - - useEffect(() => { - void refreshNotifications(); - }, [refreshNotifications]); - - useEffect(() => { - if (!isLoaded) { - setLiveActivityStatus("checking"); - return; - } - if (!isSignedIn) { - setLiveActivityStatus("signed-out"); - return; - } - if (!AsyncResult.isSuccess(preferencesResult)) { - if (AsyncResult.isFailure(preferencesResult)) { - reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); - setLiveActivityStatus("enabled"); - } else { - setLiveActivityStatus("checking"); - } - return; - } - setLiveActivityStatus( - preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", - ); - }, [isLoaded, isSignedIn, preferencesResult]); - - const requestNotifications = useCallback(async () => { - const result = await settleAsyncResult(() => - runtime.runPromiseExit( - requestAgentNotificationPermission.pipe( - Effect.tap((permission) => - permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, - ), - ), - ), - ); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - Alert.alert( - "Notifications unavailable", - error instanceof Error ? error.message : "Could not request notification permission.", - ); - } - return; - } - if (result.value.type === "granted") { - setNotificationStatus("enabled"); - // Permission alone is not enough: the switch stays off until the relay - // registration succeeds, so tell the user the truth about which happened. - if (getAgentAwarenessRegistrationStatus() === "registered") { - Alert.alert("Notifications enabled", "Agent notifications are enabled for this device."); - } else { - Alert.alert( - "Couldn't finish enabling notifications", - "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", - ); - } - return; - } - if (result.value.type === "unsupported") { - setNotificationStatus("unsupported"); - Alert.alert( - "Notifications unavailable", - "Agent notifications are unavailable on this platform.", - ); - return; - } - setNotificationStatus("disabled"); - if (result.value.canAskAgain) { - Alert.alert("Notifications disabled", "Notifications were not enabled."); - return; - } - Alert.alert( - "Notifications disabled", - "Notifications were denied for this app. Open Settings to enable them.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Open Settings", onPress: () => void Linking.openSettings() }, - ], - ); - }, []); - - const promptSignIn = useCallback(() => { - Alert.alert( - "Sign in to T3 Connect", - "Live Activity updates require T3 Connect so relay can deliver updates to this device.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Continue", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), - }, - ], - ); - }, [navigation]); - - const linkEnvironments = useCallback(async () => { - if (!isSignedIn) { - promptSignIn(); - return; - } - - setLiveActivityStatus("linking"); - if (Platform.OS === "android") { - const permission = await settleAsyncResult(() => - runtime.runPromiseExit(requestAgentNotificationPermission), - ); - if (permission._tag === "Failure") { - setLiveActivityStatus("disabled"); - const error = squashAtomCommandFailure(permission); - Alert.alert( - "Ongoing activity unavailable", - error instanceof Error ? error.message : "Could not enable agent notifications.", - ); - return; - } - if (permission.value.type !== "granted") { - setLiveActivityStatus("disabled"); - Alert.alert( - "Notification permission needed", - "Enable notifications in system Settings to show ongoing agent activity.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Open Settings", onPress: () => void Linking.openSettings() }, - ], - ); - return; - } - setNotificationStatus("enabled"); - } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - setLiveActivityStatus("disabled"); - const error = squashAtomCommandFailure(tokenResult); - Alert.alert( - Platform.OS === "android" ? "Ongoing activity unavailable" : "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable agent activity updates.", - ); - return; - } - if (!tokenResult.value) { - promptSignIn(); - setLiveActivityStatus("signed-out"); - return; - } - - const updateResult = await settleAsyncResult(() => - runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: tokenResult.value, - connections, - }), - ), - ); - if (updateResult._tag === "Failure") { - setLiveActivityStatus("disabled"); - if (!isAtomCommandInterrupted(updateResult)) { - const error = squashAtomCommandFailure(updateResult); - Alert.alert( - Platform.OS === "android" - ? "Ongoing activity unavailable" - : "Live Activities unavailable", - error instanceof Error ? error.message : "Could not enable agent activity updates.", - ); - } - return; - } - - savePreferences({ liveActivitiesEnabled: true }); - refreshManagedRelayEnvironments(); - setLiveActivityStatus("enabled"); - // The environment link can succeed while this device's own registration - // (the push-to-start token the relay needs) has not — don't claim Live - // Activities are live until the device is actually registered. - if (getAgentAwarenessRegistrationStatus() === "registered") { - Alert.alert( - Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", - environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` - : "Agent activity updates are enabled. Add an environment to start receiving updates.", - ); - } else { - Alert.alert( - "Couldn't finish enabling activity updates", - "This device could not be registered with T3 Connect, so activity updates won't appear yet. They'll start once registration succeeds.", - ); - } - }, [ - connections, - environmentCount, - getToken, - isSignedIn, - liveActivitiesPreferenceEnabled, - promptSignIn, - savePreferences, - ]); - - const handleDeviceNotificationsChange = useCallback( - (enabled: boolean) => { - if (enabled) { - if (!isSignedIn) { - promptSignIn(); - return; - } - void requestNotifications(); - return; - } - - Alert.alert( - "Disable notifications", - "Open system Settings to disable notifications for T3 Code.", - [ - { text: "Cancel", style: "cancel" }, - { text: "Open Settings", onPress: () => void Linking.openSettings() }, - ], - ); - }, - [isSignedIn, promptSignIn, requestNotifications], - ); - - const handleLiveActivitiesChange = useCallback( - (enabled: boolean) => { - if (!enabled) { - setLiveActivityStatus("disabled"); - void (async () => { - let token: string | null = null; - if (isSignedIn) { - const tokenResult = await settlePromise(() => - getToken(resolveRelayClerkTokenOptions()), - ); - if (tokenResult._tag === "Failure") { - reportAtomCommandResult(tokenResult, { - label: "live activity disable token lookup", - }); - return; - } - token = tokenResult.value; - } - - const updateResult = await settleAsyncResult(() => - runtime.runPromiseExit( - setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: liveActivitiesPreferenceEnabled, - clerkToken: token, - connections, - }), - ), - ); - if (updateResult._tag === "Failure") { - setLiveActivityStatus("enabled"); - reportAtomCommandResult(updateResult, { - label: "live activity disable", - }); - return; - } - savePreferences({ liveActivitiesEnabled: false }); - refreshManagedRelayEnvironments(); - })(); - return; - } - - if (!isSignedIn) { - promptSignIn(); - return; - } - - void linkEnvironments(); - }, - [ - connections, - getToken, - isSignedIn, - linkEnvironments, - liveActivitiesPreferenceEnabled, - promptSignIn, - savePreferences, - ], + return ( + + + + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" })} + /> + + + + + + + ); +} - const openAccount = useCallback(() => { - if (!isLoaded) return; - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }, [isLoaded, navigation]); +function LocalSettingsRouteScreen() { + const insets = useSafeAreaInsets(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const environmentCount = Object.keys(savedConnectionsById).length; return ( @@ -495,26 +95,12 @@ function ConfiguredSettingsRouteScreen() { contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} className="flex-1" - contentContainerClassName="gap-6 px-5 pt-4" + contentContainerClassName="gap-4 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18, }} > - - - - - - T3 Code works locally without signing in. Cloud features are optional. - - - - + - - - {supportsAndroidLiveUpdateSettings() ? ( - { - void openAndroidLiveUpdateSettings().catch(() => { - Alert.alert( - "Couldn't open Settings", - "Open Android Settings, select T3 Code, then enable Live Updates in Notifications.", - ); - }); - }} - /> - ) : null} - - - - - - - - - - - + ); } -function GeneralSettingsSection() { - return ( - - - {Platform.OS === "ios" ? ( - - ) : null} - - - - ); -} - -const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; - -/** - * Mobile edits auto-settle defaults across connected, capable environments. - * The first target supplies the displayed values. Applying them leaves each - * environment's other defaults and overrides intact. - */ -function AutoSettleSettingsRows() { - const { environments } = useEnvironments(); - const [pendingWrites, setPendingWrites] = useState(0); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "server settings update", - reportFailure: true, - }); - - const syncTargets = environments.filter(supportsSharedSettingsSync); - const reference = syncTargets[0] ?? null; - const referenceSettings = reference?.serverConfig?.settings ?? null; - - if (reference === null || referenceSettings === null) { - return null; - } - - const writeToAll = (patch: Partial) => { - setPendingWrites((count) => count + 1); - void Promise.allSettled( - syncTargets.map((environment) => - updateSettings({ environmentId: environment.environmentId, input: { patch } }), - ), - ).finally(() => setPendingWrites((count) => count - 1)); - }; - - const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( - { environmentId: reference.environmentId, settings: referenceSettings }, - syncTargets.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - settings: environment.serverConfig?.settings ?? null, - })), - ); - - const afterDays = referenceSettings.sidebarAutoSettleAfterDays; - +function SettingsIndexSections() { + const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); + const noServerTargets = selectedTargets.length === 0; + const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); + const scopedProjectMembers = + selectedProject?.members + .map((member) => member.project) + .filter((project) => + selectedTargets.some((target) => target.environmentId === project.environmentId), + ) ?? []; + const projectLabel = + scopedProjectMembers.length > 0 + ? deriveProjectGroupLabel({ + representative: scopedProjectMembers[0]!, + members: scopedProjectMembers, + }) + : (selectedProject?.label ?? "Unavailable project"); return ( <> - writeToAll({ sidebarAutoSettleOnMerge: value })} - /> - - writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) - } - /> - {afterDays !== null ? ( - - - - Inactive days - - writeToAll({ sidebarAutoSettleAfterDays: value })} + + + {Platform.OS === "ios" ? ( + + ) : null} + + + + {selectedProjectKey !== null ? ( + - - ) : null} - {pendingWrites === 0 && mismatches.length > 0 ? ( - - - Auto-settle defaults differ - - {mismatches.map((mismatch) => mismatch.label).join(", ")} - - - writeToAll(autoSettlePatch)} - className="rounded-full bg-subtle px-4 py-2 active:opacity-70" - > - - Apply auto-settle defaults - - - - ) : null} + ) : null} + + + + + + + + + + + + + + + + ); } - -/** - * Device-local legacy toggles. Mobile has no client-settings sync, so this is - * the counterpart of web's Settings → General → Legacy features backed by - * mobile preferences. - */ -function LegacySettingsSection() { - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const preferences = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = useThreadListV2Enabled(); - const planModeEnabled = - AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; - - return ( - - - savePreferences({ legacyThreadListEnabled: value })} - /> - savePreferences({ planModeEnabled: value })} - /> - - - Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan - control; otherwise every task runs in Build mode. - - - ); -} - -function AppSettingsSection() { - const [updateState, setUpdateState] = useState("idle"); - const updateInFlight = useRef(false); - const hiddenUpdateTapCount = useRef(0); - - const version = Constants.expoConfig?.version ?? "0.0.0"; - // Fall back to "production" to match resolveAppVariant in app.config.ts, so a - // missing variant never mislabels a production build as development. - const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; - const variantLabel = variant === "production" ? "" : capitalize(variant); - const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; - const updateCheckAvailable = isAppUpdateCheckAvailable(); - const busy = - updateState === "checking" || updateState === "downloading" || updateState === "restarting"; - - // "Up to date" is a transient acknowledgement, not a state worth persisting — - // return the version row to its normal, deliberately quiet state. - useEffect(() => { - if (updateState !== "current") return; - const timer = setTimeout(() => setUpdateState("idle"), 3000); - return () => clearTimeout(timer); - }, [updateState]); - - const checkForUpdate = useCallback(async () => { - // `disabled={busy}` only takes effect on the next render, so two taps in the - // same frame would both get through. The ref closes that window. - if (updateInFlight.current) return; - updateInFlight.current = true; - try { - // The user asked for this restart by tapping the version row, so it may - // apply immediately instead of prompting. - await runAppUpdateCheck({ - applyMode: "immediate", - onFailure: (message) => Alert.alert("Update failed", message), - onStateChange: setUpdateState, - }); - } finally { - updateInFlight.current = false; - } - }, []); - - const handleVersionPress = useCallback(() => { - if (!updateCheckAvailable || updateInFlight.current) return; - const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); - hiddenUpdateTapCount.current = tap.nextCount; - if (tap.shouldCheck) { - void checkForUpdate(); - } - }, [checkForUpdate, updateCheckAvailable]); - - const statusLabel = - updateState === "checking" - ? "Checking…" - : updateState === "downloading" - ? "Downloading…" - : // "ready" appears only when this check joined an in-flight background-mode - // check; that download installs at the next backgrounding. - updateState === "ready" - ? "Update ready" - : updateState === "restarting" - ? "Restarting…" - : updateState === "current" - ? "Up to date" - : null; - - const versionRow = ( - - - Version - - {versionLabel} - {statusLabel ? ( - {statusLabel} - ) : null} - - - ); - - return ( - - - - - - {updateCheckAvailable ? ( - - {versionRow} - - ) : ( - versionRow - )} - - ); -} - -function capitalize(value: string): string { - return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; -} - -function ArchivedThreadsSettingsSection() { - return ( - - - - ); -} diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e7cef0de93dc3ee34de115aca829da3ca9b9ace --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -0,0 +1,474 @@ +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { + type ResponseStreamingMode, + type ServerSettings, + type ServerSettingsPatch, + type ThreadEnvMode, + PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectScopedServerSettingKey, +} from "@t3tools/contracts"; +import { useRef, useState, type ComponentProps } from "react"; +import { Alert, Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { + AndroidSettingsEnvironmentFilter, + SettingsEnvironmentFilterHeader, +} from "./components/SettingsEnvironmentFilterHeader"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; +import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { + planMobileScopedSettingsClear, + planMobileScopedSettingsPatch, + resolveMobileSettingsTargets, + type ScopedMobileSettingsTarget, +} from "./settings-scoped-server"; + +type SettingsPage = "new-threads" | "source-control" | "agent-behavior" | "maintenance"; + +const PAGE_TITLES: Record = { + "new-threads": "New threads", + "source-control": "Source control", + "agent-behavior": "Agent behavior", + maintenance: "Maintenance", +}; + +const PAGE_PROJECT_KEYS: Record = { + "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], + "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], + "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], + maintenance: ["continueThreadsAfterServerUpdate"], +}; + +const WORKSPACE_CHOICES: ReadonlyArray<{ + readonly mode: ThreadEnvMode; + readonly label: string; + readonly description: string; +}> = [ + { + mode: "local", + label: "Current checkout", + description: "Start new threads in the existing workspace.", + }, + { + mode: "worktree", + label: "New worktree", + description: "Give each new thread a separate checkout.", + }, +]; + +const STREAMING_CHOICES: ReadonlyArray<{ + readonly mode: ResponseStreamingMode; + readonly label: string; + readonly description: string; +}> = [ + { + mode: "turn", + label: "After the turn", + description: "Show the answer when the agent finishes.", + }, + { + mode: "paragraph", + label: "Finished paragraphs", + description: "Show each paragraph or code block as it completes.", + }, + { + mode: "token", + label: "Token by token (legacy)", + description: "Repaint for every token; this can be slower.", + }, +]; + +export function SettingsEnvironmentNewThreadsRouteScreen() { + return ; +} + +export function SettingsEnvironmentSourceControlRouteScreen() { + return ; +} + +export function SettingsEnvironmentAgentBehaviorRouteScreen() { + return ; +} + +export function SettingsEnvironmentMaintenanceRouteScreen() { + return ; +} + +function ServerSettingsDetail(props: { readonly page: SettingsPage }) { + const insets = useSafeAreaInsets(); + const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); + const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); + const projectSelected = selectedProjectKey !== null; + const targets = resolveMobileSettingsTargets( + selectedTargets, + projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, + ); + const [pendingWrites, setPendingWrites] = useState(0); + const writeInFlight = useRef(false); + const [pendingTargets, setPendingTargets] = useState< + readonly ScopedMobileSettingsTarget[] | null + >(null); + const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets; + const hasConnectedSelection = targets.length > 0; + const reference = displayTargets[0] ?? null; + const uniform = (key: K): ServerSettings[K] | null => { + if (reference === null) return null; + const value = reference.settings[key]; + return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; + }; + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "environment settings update", + reportFailure: true, + }); + const write = (patch: ServerSettingsPatch) => { + if (writeInFlight.current || !hasConnectedSelection) return; + const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(targets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + const clearProjectOverrides = () => { + if (writeInFlight.current) return; + const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(targets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + const supportsProjectOverrides = targets.every( + (target) => + target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, + ); + const disabled = + pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides); + const supportsContinuation = targets.every( + (target) => + target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, + ); + const disabledFor = (key: string) => + disabled || + (projectSelected && + !PROJECT_SCOPED_SERVER_SETTING_KEYS.includes( + key as (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number], + )); + + return ( + <> + + } + > + + {!hasConnectedSelection || reference === null ? ( + + {projectSelected + ? "Select a project with a checkout on a connected environment." + : "Use the filter above to select a connected environment."} + + ) : ( + <> + {projectSelected ? ( + + PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"), + )} + supportsOverrides={supportsProjectOverrides} + pending={pendingWrites > 0} + onClear={clearProjectOverrides} + /> + ) : null} + {props.page === "new-threads" ? ( + <> + + ) : null + } + > + {WORKSPACE_CHOICES.map((choice, index) => ( + 0} + disabled={disabledFor("defaultThreadEnvMode")} + onPress={() => write({ defaultThreadEnvMode: choice.mode })} + /> + ))} + + + ) : null + } + > + {RUNTIME_MODE_CHOICES.map((choice, index) => ( + 0} + disabled={disabledFor("defaultRuntimeMode")} + onPress={() => write({ defaultRuntimeMode: choice.mode })} + /> + ))} + + + ) : null} + + {props.page === "source-control" ? ( + <> + + write({ defaultAutoPull: value })} + /> + + + write({ newWorktreesStartFromOrigin: value })} + /> + + + ) : null} + + {props.page === "agent-behavior" ? ( + <> + + ) : null + } + > + {STREAMING_CHOICES.map((choice, index) => ( + 0} + disabled={disabledFor("responseStreamingMode")} + onPress={() => { + if (choice.mode !== "token") { + write({ responseStreamingMode: choice.mode }); + return; + } + Alert.alert( + "Use legacy token streaming?", + "Repainting every token can make the app slower.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Use token streaming", + onPress: () => write({ responseStreamingMode: "token" }), + }, + ], + ); + }} + /> + ))} + + + write({ enableAgentBrowserAccess: value })} + /> + + + ) : null} + + {props.page === "maintenance" ? ( + + write({ enableProviderUpdateChecks: value })} + /> + + write({ continueThreadsAfterServerUpdate: value })} + /> + + + ) : null} + + )} + + + + ); +} + +function ChoiceRow(props: { + readonly label: string; + readonly description: string; + readonly selected: boolean; + readonly separated: boolean; + readonly disabled: boolean; + readonly onPress: () => void; +}) { + return ( + + + + {props.label} + + {props.description} + + {props.selected ? ( + + ) : null} + + ); +} + +function MixedValuesLabel(props: { readonly projectSelected: boolean }) { + return ( + + Mixed + + ); +} + +function FanoutSwitchRow(props: { + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly subtitle: string; + readonly value: boolean | null; + readonly disabled: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + if (props.value !== null) { + return ( + + ); + } + + return ( + + + + {props.label} + {props.subtitle} + + props.onValueChange(true)} + > + Mixed · Set on + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd8ec325215561d19867403b39e37be99c767bf3 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -0,0 +1,266 @@ +import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useRef, useState } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { + AndroidSettingsEnvironmentFilter, + SettingsEnvironmentFilterHeader, +} from "./components/SettingsEnvironmentFilterHeader"; +import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; +import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { + planMobileScopedSettingsClear, + planMobileScopedSettingsPatch, + resolveMobileSettingsTargets, + type ScopedMobileSettingsTarget, +} from "./settings-scoped-server"; + +export function SettingsThreadsRouteScreen() { + const insets = useSafeAreaInsets(); + + return ( + <> + + }> + + + + + + + ); +} + +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; + +/** + * Mobile edits auto-settle defaults across selected capable targets. + */ +function AutoSettleSettingsRows() { + const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); + const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); + const projectSelected = selectedProjectKey !== null; + const [pendingWrites, setPendingWrites] = useState(0); + const writeInFlight = useRef(false); + const [pendingTargets, setPendingTargets] = useState< + readonly ScopedMobileSettingsTarget[] | null + >(null); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "server settings update", + reportFailure: true, + }); + + const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync); + const syncTargets = resolveMobileSettingsTargets( + syncEnvironments, + projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, + ); + const displayTargets = + pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; + const reference = displayTargets[0] ?? null; + const referenceSettings = reference?.settings ?? null; + + if (reference === null || referenceSettings === null) { + return null; + } + + const writeToAll = (patch: Partial) => { + if (writeInFlight.current) return; + const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(syncTargets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + + const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( + { + environmentId: reference.environment.environmentId, + projectId: reference.projectId, + settings: referenceSettings, + }, + displayTargets.map((target) => ({ + environmentId: target.environment.environmentId, + projectId: target.projectId, + label: target.environment.label, + settings: target.settings, + })), + ); + + const supportsProjectOverrides = syncTargets.every( + (target) => + target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, + ); + const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); + const hasProjectOverrides = + projectSelected && + syncTargets.some( + (target) => + target.sources.sidebarAutoSettleOnMerge === "project" || + target.sources.sidebarAutoSettleAfterDays === "project", + ); + const clearProjectOverrides = () => { + if (writeInFlight.current) return; + const writes = planMobileScopedSettingsClear(syncTargets, [ + "sidebarAutoSettleOnMerge", + "sidebarAutoSettleAfterDays", + ]); + if (writes.length === 0) return; + writeInFlight.current = true; + setPendingTargets(syncTargets); + setPendingWrites((count) => count + 1); + void Promise.allSettled( + writes.map((entry) => + updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), + ), + ).finally(() => { + writeInFlight.current = false; + setPendingTargets(null); + setPendingWrites((count) => count - 1); + }); + }; + + const afterDays = referenceSettings.sidebarAutoSettleAfterDays; + + return ( + + {projectSelected ? ( + 0} + onClear={clearProjectOverrides} + /> + ) : null} + + writeToAll({ sidebarAutoSettleOnMerge: value })} + /> + + writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) + } + /> + {afterDays !== null ? ( + + + + Inactive days + + writeToAll({ sidebarAutoSettleAfterDays: value })} + /> + + ) : null} + + {pendingWrites === 0 && mismatches.length > 0 ? ( + + + Auto-settle defaults differ + + {mismatches.map((mismatch) => mismatch.label).join(", ")} + + writeToAll(autoSettlePatch)} + className="self-start rounded-full bg-subtle px-4 py-2 active:opacity-70" + > + + Apply auto-settle defaults + + + + + ) : null} + + ); +} + +/** + * Device-local legacy toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → General → Legacy features backed by + * mobile preferences. + */ +function LegacySettingsSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = useThreadListV2Enabled(); + const planModeEnabled = + AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; + + return ( + + + savePreferences({ legacyThreadListEnabled: value })} + /> + savePreferences({ planModeEnabled: value })} + /> + + + Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan + control; otherwise every task runs in Build mode. + + + ); +} diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts index ec550725adcf90e8cb61043d2efac51ec12b0682..996ebbd4979d1a6cf1615552a213a43cd8e3101b 100644 --- a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; @@ -75,4 +75,18 @@ describe("auto-settle settings sync", () => { expect(plan.mismatches).toEqual([]); }); + + it("notices different project checkouts on the same environment", () => { + const projectReference = { ...reference, projectId: ProjectId.make("one") }; + const otherCheckout = { + environmentId: reference.environmentId, + projectId: ProjectId.make("two"), + label: "Other checkout", + settings: { ...reference.settings, sidebarAutoSettleAfterDays: null }, + }; + + expect(planAutoSettleSettingsSync(projectReference, [otherCheckout]).mismatches).toEqual([ + otherCheckout, + ]); + }); }); diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts index 6addfa381fdedadc525d319a4b435a90c01da21f..bbaaaa23d5b4a2606fb35d493842692654ac49a5 100644 --- a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ServerSettings } from "@t3tools/contracts"; export type AutoSettleSettings = Pick< ServerSettings, @@ -7,13 +7,18 @@ export type AutoSettleSettings = Pick< interface AutoSettleSyncTarget { readonly environmentId: EnvironmentId; + readonly projectId?: ProjectId | null; readonly label: string; readonly settings: AutoSettleSettings | null; } /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ export function planAutoSettleSettingsSync( - reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, + reference: { + readonly environmentId: EnvironmentId; + readonly projectId?: ProjectId | null; + readonly settings: AutoSettleSettings; + }, targets: readonly AutoSettleSyncTarget[], ) { const patch: AutoSettleSettings = { @@ -22,7 +27,8 @@ export function planAutoSettleSettingsSync( }; const mismatches = targets.filter( (target) => - target.environmentId !== reference.environmentId && + (target.environmentId !== reference.environmentId || + target.projectId !== reference.projectId) && target.settings !== null && (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx index bc9ccafecf7ef197c7d1517e76492a80b50bd530..572812880ada5bb4b001856a307a999e38d716f0 100644 --- a/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.android.tsx @@ -10,6 +10,7 @@ import type { AutoSettleDaysFieldProps } from "./AutoSettleDaysField"; export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { const adjust = (amount: number) => { + if (props.disabled) return; const next = Math.max( MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, Math.min(MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, props.value + amount), @@ -28,7 +29,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { adjust(-1)} /> = MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} + disabled={props.disabled || props.value >= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} onPress={() => adjust(1)} /> diff --git a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx index 19a8998084c6d25830efa1fed178c9164a91c746..2c77aa55571d9c0c83b9e6b16febaefa3f98a453 100644 --- a/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx +++ b/apps/mobile/src/features/settings/components/AutoSettleDaysField.ios.tsx @@ -2,6 +2,7 @@ import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/sw import { accessibilityLabel, buttonStyle, + disabled, font, foregroundStyle, frame, @@ -34,11 +35,13 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) {