1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
3import { AutoSettleDaysField } from "./components/AutoSettleDaysField";
4import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
5import { useAtomSet, useAtomValue } from "@effect/atom-react";
6import { AsyncResult } from "effect/reactivity";
7import { useRef, useState } from "react";
8import { Pressable, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
12import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
13import { AppText as Text } from "../../components/AppText";
14import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
15import { serverEnvironment } from "../../state/server";
16import { useAtomCommand } from "../../state/use-atom-command";
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 uniformMobileSetting,
32 type ScopedMobileSettingsTarget,
33} from "./settings-scoped-server";
34
35export function SettingsThreadsRouteScreen() {
36 const insets = useSafeAreaInsets();
37
38 return (
39 <>
40 <SettingsEnvironmentFilterHeader />
41 <SettingsScreen title="Thread behavior" trailing={<AndroidSettingsEnvironmentFilter />}>
42 <ScrollView
43 contentInsetAdjustmentBehavior="automatic"
44 showsVerticalScrollIndicator={false}
45 className="flex-1"
46 contentContainerClassName="gap-6 px-5 pt-4"
47 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
48 >
49 <AutoSettleSettingsRows />
50 <BetaSettingsSection />
51 <LegacySettingsSection />
52 </ScrollView>
53 </SettingsScreen>
54 </>
55 );
56}
57
58const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
59
60/**
61 * Mobile edits auto-settle defaults across selected capable targets.
62 */
63function AutoSettleSettingsRows() {
64 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
65 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
66 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
67 const projectSelected = selectedProjectKey !== null;
68 const [pendingWrites, setPendingWrites] = useState(0);
69 const writeInFlight = useRef(false);
70 const [pendingTargets, setPendingTargets] = useState<
71 readonly ScopedMobileSettingsTarget[] | null
72 >(null);
73 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
74 label: "server settings update",
75 reportFailure: true,
76 });
77
78 const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync);
79 const syncTargets = resolveMobileSettingsTargets(
80 syncEnvironments,
81 projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null,
82 );
83 const canWriteSettings =
84 syncTargets.length > 0 &&
85 syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId));
86 const displayTargets =
87 pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets;
88 const reference = displayTargets[0] ?? null;
89 const referenceSettings = reference?.settings ?? null;
90
91 if (reference === null || referenceSettings === null) {
92 return null;
93 }
94
95 const writeToAll = (
96 patch: Partial<AutoSettleSettings> & {
97 autoResumeLimitedThreads?: boolean;
98 snoozeLimitedThreads?: boolean;
99 },
100 ) => {
101 if (
102 writeInFlight.current ||
103 !syncTargets.every((target) =>
104 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
105 )
106 )
107 return;
108 const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch);
109 if (writes.length === 0) return;
110 writeInFlight.current = true;
111 setPendingTargets(syncTargets);
112 setPendingWrites((count) => count + 1);
113 void Promise.allSettled(
114 writes.map((entry) =>
115 updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
116 ),
117 ).finally(() => {
118 writeInFlight.current = false;
119 setPendingTargets(null);
120 setPendingWrites((count) => count - 1);
121 });
122 };
123
124 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
125 {
126 environmentId: reference.environment.environmentId,
127 projectId: reference.projectId,
128 settings: referenceSettings,
129 },
130 displayTargets.map((target) => ({
131 environmentId: target.environment.environmentId,
132 projectId: target.projectId,
133 label: target.environment.label,
134 settings: target.settings,
135 })),
136 );
137
138 const supportsProjectOverrides = syncTargets.every(
139 (target) =>
140 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
141 );
142 const disabled =
143 !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
144 const hasProjectOverrides =
145 projectSelected &&
146 syncTargets.some(
147 (target) =>
148 target.sources.sidebarAutoSettleOnMerge === "project" ||
149 target.sources.sidebarAutoSettleAfterDays === "project",
150 );
151 const clearProjectOverrides = () => {
152 if (
153 writeInFlight.current ||
154 !syncTargets.every((target) =>
155 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
156 )
157 )
158 return;
159 const writes = planMobileScopedSettingsClear(syncTargets, [
160 "sidebarAutoSettleOnMerge",
161 "sidebarAutoSettleAfterDays",
162 ]);
163 if (writes.length === 0) return;
164 writeInFlight.current = true;
165 setPendingTargets(syncTargets);
166 setPendingWrites((count) => count + 1);
167 void Promise.allSettled(
168 writes.map((entry) =>
169 updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
170 ),
171 ).finally(() => {
172 writeInFlight.current = false;
173 setPendingTargets(null);
174 setPendingWrites((count) => count - 1);
175 });
176 };
177
178 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
179
180 return (
181 <View className="gap-6">
182 {projectSelected ? (
183 <SettingsProjectOverridesSection
184 projectLabel={selectedProject?.label ?? "Unavailable project"}
185 hasOverrides={hasProjectOverrides}
186 supportsOverrides={supportsProjectOverrides}
187 pending={pendingWrites > 0}
188 disabled={!canWriteSettings}
189 onClear={clearProjectOverrides}
190 />
191 ) : null}
192 {!projectSelected ? (
193 <SettingsSection title="Usage limits">
194 <SettingsSwitchRow
195 icon="clock"
196 label="Auto-resume limited threads"
197 value={uniformMobileSetting(displayTargets, "autoResumeLimitedThreads")}
198 disabled={disabled}
199 onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })}
200 />
201 <SettingsSwitchRow
202 icon="clock"
203 label="Snooze limited threads"
204 value={uniformMobileSetting(displayTargets, "snoozeLimitedThreads")}
205 disabled={disabled}
206 onValueChange={(value) => writeToAll({ snoozeLimitedThreads: value })}
207 />
208 </SettingsSection>
209 ) : null}
210 <SettingsSection title="Auto-settle">
211 <SettingsSwitchRow
212 icon="arrow.triangle.branch"
213 label="Auto-settle merged threads"
214 value={referenceSettings.sidebarAutoSettleOnMerge}
215 disabled={disabled}
216 onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })}
217 />
218 <SettingsSwitchRow
219 icon="clock"
220 label="Auto-settle inactive threads"
221 value={afterDays !== null}
222 disabled={disabled}
223 onValueChange={(value) =>
224 writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })
225 }
226 />
227 {afterDays !== null ? (
228 <View className="flex-row items-center gap-4 px-4 py-4 android:min-h-14 android:py-3">
229 <View className="w-[22px] android:w-6" />
230 <Text className="flex-1 text-foreground text-lg android:text-base">Inactive days</Text>
231 <AutoSettleDaysField
232 value={afterDays}
233 disabled={disabled}
234 onValueChange={(value) => writeToAll({ sidebarAutoSettleAfterDays: value })}
235 />
236 </View>
237 ) : null}
238 </SettingsSection>
239 {pendingWrites === 0 && mismatches.length > 0 ? (
240 <SettingsSection title="Across environments">
241 <View className="gap-3 p-4">
242 <Text className="text-base text-foreground">Auto-settle defaults differ</Text>
243 <Text className="text-sm text-foreground-muted">
244 {mismatches.map((mismatch) => mismatch.label).join(", ")}
245 </Text>
246 <Pressable
247 accessibilityRole="button"
248 disabled={disabled}
249 onPress={() => writeToAll(autoSettlePatch)}
250 className="self-start rounded-full bg-subtle px-4 py-2 active:opacity-70"
251 >
252 <Text className="text-sm font-t3-medium text-foreground">
253 Apply auto-settle defaults
254 </Text>
255 </Pressable>
256 </View>
257 </SettingsSection>
258 ) : null}
259 </View>
260 );
261}
262
263/**
264 * Device-local beta toggles, the counterpart of web's Working section (beta)
265 * in Settings → General.
266 */
267function BetaSettingsSection() {
268 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
269 const preferences = useAtomValue(mobilePreferencesAtom);
270 const workingShelfEnabled =
271 AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true;
272
273 return (
274 <View className="gap-3">
275 <SettingsSection title="Beta">
276 <SettingsSwitchRow
277 icon="bolt.circle"
278 label="Working section"
279 value={workingShelfEnabled}
280 onValueChange={(value) => savePreferences({ workingShelfEnabled: value })}
281 />
282 </SettingsSection>
283 <Text className="px-2 text-sm text-foreground-muted">
284 Fold working and monitoring threads into a Working section. They return to the top of the
285 list when they need you. While this is on, active threads are ordered by time and cannot be
286 moved.
287 </Text>
288 </View>
289 );
290}
291
292/**
293 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
294 * the counterpart of web's Settings → General → Legacy features backed by
295 * mobile preferences.
296 */
297function LegacySettingsSection() {
298 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
299 const preferences = useAtomValue(mobilePreferencesAtom);
300 const planModeEnabled =
301 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
302
303 return (
304 <View className="gap-3">
305 <SettingsSection title="Legacy">
306 <SettingsSwitchRow
307 icon="hammer"
308 label="Plan Mode"
309 value={planModeEnabled}
310 onValueChange={(value) => savePreferences({ planModeEnabled: value })}
311 />
312 </SettingsSection>
313 <Text className="px-2 text-sm text-foreground-muted">
314 Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan
315 control; otherwise every task runs in Build mode.
316 </Text>
317 </View>
318 );
319}