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) {