authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 10:56:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 10:56:01-07:00
logc25c3e0ee678ef62245cb5eef20eeafb9a86d9d9
tree7a450ec29acb59d6088c4ec6a205204afdd20221
parent2d8378b4404888f0d5458e8fc9c83b1032e21290
signature Signed by PGP key B5690EEEBB952194

feat(mobile): make Settings easier to navigate and scope (#12272)


27 files changed, 2382 insertions(+), 816 deletions(-)

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