1import { useNavigation } from "@react-navigation/native";
2import { SettingsRow } from "./components/SettingsRow";
3import { AuthSettingsWriteScope } from "@t3tools/contracts";
4import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
5import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
6import { AppText as Text } from "../../components/AppText";
7import {
8 type ResponseStreamingMode,
9 type ServerSettings,
10 type ServerSettingsPatch,
11 type ThreadEnvMode,
12 type WorktreeSubmodules,
13 PROJECT_SCOPED_SERVER_SETTING_KEYS,
14 type ProjectScopedServerSettingKey,
15} from "@t3tools/contracts";
16import { useRef, useState } from "react";
17import { View } from "react-native";
18import { useSafeAreaInsets } from "react-native-safe-area-context";
19
20import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options";
21import { serverEnvironment } from "../../state/server";
22import { useAtomCommand } from "../../state/use-atom-command";
23import { SettingsScreen } from "./components/SettingsScreen";
24import {
25 AndroidSettingsEnvironmentFilter,
26 SettingsEnvironmentFilterHeader,
27} from "./components/SettingsEnvironmentFilterHeader";
28import { BranchNamingSettings } from "./components/BranchNamingSettings";
29import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
30import { SettingsSection } from "./components/SettingsSection";
31import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
32import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
33import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
34import {
35 planMobileScopedSettingsClear,
36 planMobileScopedSettingsPatch,
37 resolveMobileSettingsTargets,
38 uniformMobileSetting,
39 type ScopedMobileSettingsTarget,
40} from "./settings-scoped-server";
41
42type SettingsPage = "new-threads" | "source-control" | "agent-behavior" | "maintenance";
43
44const PAGE_TITLES: Record<SettingsPage, string> = {
45 "new-threads": "New threads",
46 "source-control": "Source control",
47 "agent-behavior": "Agent behavior",
48 maintenance: "Maintenance",
49};
50
51const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
52 "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
53 "source-control": [
54 "defaultAutoPull",
55 "removeAgentCreditsOnMerge",
56 "newWorktreesStartFromOrigin",
57 "branchNamingMode",
58 "branchNamePrefix",
59 "branchNameInstructions",
60 ],
61 "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
62 maintenance: ["continueThreadsAfterServerUpdate"],
63};
64
65const SUBMODULE_CHOICES: ReadonlyArray<{
66 readonly mode: WorktreeSubmodules | null;
67 readonly label: string;
68 readonly description: string;
69}> = [
70 // Only offered at environment scope; a project falls back through "Use defaults".
71 {
72 mode: null,
73 label: "Inherit",
74 description: "Use the repository's t3.json, or initialize recursively.",
75 },
76 { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." },
77 {
78 mode: "top-level",
79 label: "Top level only",
80 description: "Skip submodules declared inside other submodules.",
81 },
82 { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." },
83];
84
85const WORKSPACE_CHOICES: ReadonlyArray<{
86 readonly mode: ThreadEnvMode | null;
87 readonly label: string;
88 readonly description: string;
89}> = [
90 // Only offered at environment scope; a project falls back through "Use defaults".
91 {
92 mode: null,
93 label: "Inherit",
94 description: "Use the repository's t3.json, or the current checkout.",
95 },
96 {
97 mode: "local",
98 label: "Current checkout",
99 description: "Start new threads in the existing workspace.",
100 },
101 {
102 mode: "worktree",
103 label: "New worktree",
104 description: "Give each new thread a separate checkout.",
105 },
106];
107
108const STREAMING_CHOICES: ReadonlyArray<{
109 readonly mode: ResponseStreamingMode;
110 readonly label: string;
111 readonly description: string;
112}> = [
113 {
114 mode: "turn",
115 label: "After the turn",
116 description: "Show the answer when the agent finishes.",
117 },
118 {
119 mode: "paragraph",
120 label: "Finished paragraphs",
121 description: "Show each paragraph or code block as it completes.",
122 },
123];
124
125export function SettingsEnvironmentNewThreadsRouteScreen() {
126 return <ServerSettingsDetail page="new-threads" />;
127}
128
129export function SettingsEnvironmentSourceControlRouteScreen() {
130 return <ServerSettingsDetail page="source-control" />;
131}
132
133export function SettingsEnvironmentAgentBehaviorRouteScreen() {
134 return <ServerSettingsDetail page="agent-behavior" />;
135}
136
137export function SettingsEnvironmentMaintenanceRouteScreen() {
138 return <ServerSettingsDetail page="maintenance" />;
139}
140
141function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
142 const insets = useSafeAreaInsets();
143 const navigation = useNavigation();
144 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
145 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
146 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
147 const projectSelected = selectedProjectKey !== null;
148 const targets = resolveMobileSettingsTargets(
149 selectedTargets,
150 projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null,
151 );
152 const [pendingWrites, setPendingWrites] = useState(0);
153 const writeInFlight = useRef(false);
154 const [pendingTargets, setPendingTargets] = useState<
155 readonly ScopedMobileSettingsTarget[] | null
156 >(null);
157 const canWriteSettings =
158 targets.length > 0 &&
159 targets.every((target) => writableEnvironments.has(target.environment.environmentId));
160 const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets;
161 const hasConnectedSelection = targets.length > 0;
162 const reference = displayTargets[0] ?? null;
163 const uniform = <K extends keyof ServerSettings>(key: K) =>
164 uniformMobileSetting(displayTargets, key);
165 // `uniform` folds a real null into "mixed"; nullable keys need the distinction.
166 const isMixed = (key: keyof ServerSettings) =>
167 reference === null ||
168 displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]);
169 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
170 label: "environment settings update",
171 reportFailure: true,
172 });
173 const write = (patch: ServerSettingsPatch) => {
174 if (
175 writeInFlight.current ||
176 !hasConnectedSelection ||
177 !targets.every((target) =>
178 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
179 )
180 )
181 return;
182 const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch);
183 if (writes.length === 0) return;
184 writeInFlight.current = true;
185 setPendingTargets(targets);
186 setPendingWrites((count) => count + 1);
187 void Promise.allSettled(
188 writes.map((entry) =>
189 updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
190 ),
191 ).finally(() => {
192 writeInFlight.current = false;
193 setPendingTargets(null);
194 setPendingWrites((count) => count - 1);
195 });
196 };
197 const clearProjectOverrides = () => {
198 if (
199 writeInFlight.current ||
200 !targets.every((target) =>
201 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
202 )
203 )
204 return;
205 const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]);
206 if (writes.length === 0) return;
207 writeInFlight.current = true;
208 setPendingTargets(targets);
209 setPendingWrites((count) => count + 1);
210 void Promise.allSettled(
211 writes.map((entry) =>
212 updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
213 ),
214 ).finally(() => {
215 writeInFlight.current = false;
216 setPendingTargets(null);
217 setPendingWrites((count) => count - 1);
218 });
219 };
220 const supportsProjectOverrides = targets.every(
221 (target) =>
222 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
223 );
224 const disabled =
225 !canWriteSettings ||
226 pendingWrites > 0 ||
227 !hasConnectedSelection ||
228 (projectSelected && !supportsProjectOverrides);
229 const supportsContinuation = targets.every(
230 (target) =>
231 target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,
232 );
233 const disabledFor = (key: string) =>
234 disabled ||
235 (projectSelected &&
236 !PROJECT_SCOPED_SERVER_SETTING_KEYS.includes(
237 key as (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number],
238 ));
239
240 return (
241 <>
242 <SettingsEnvironmentFilterHeader />
243 <SettingsScreen
244 title={PAGE_TITLES[props.page]}
245 trailing={<AndroidSettingsEnvironmentFilter />}
246 >
247 <ScrollView
248 contentInsetAdjustmentBehavior="automatic"
249 showsVerticalScrollIndicator={false}
250 className="flex-1"
251 contentContainerClassName="gap-6 px-5 pt-4"
252 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
253 >
254 {!hasConnectedSelection || reference === null ? (
255 <Text className="px-2 text-base text-foreground-muted">
256 {projectSelected
257 ? "Select a project with a checkout on a connected environment."
258 : "Use the filter above to select a connected environment."}
259 </Text>
260 ) : (
261 <>
262 {projectSelected ? (
263 <SettingsProjectOverridesSection
264 projectLabel={selectedProject?.label ?? "Unavailable project"}
265 hasOverrides={targets.some((target) =>
266 PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"),
267 )}
268 supportsOverrides={supportsProjectOverrides}
269 pending={pendingWrites > 0}
270 disabled={!canWriteSettings}
271 onClear={clearProjectOverrides}
272 />
273 ) : null}
274 {props.page === "new-threads" ? (
275 <>
276 <SettingsSection
277 title="Default workspace"
278 trailing={
279 pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? (
280 <MixedValuesLabel projectSelected={projectSelected} />
281 ) : null
282 }
283 >
284 {WORKSPACE_CHOICES.filter(
285 (choice) => choice.mode !== null || !projectSelected,
286 ).map((choice, index) => (
287 <SettingsChoiceRow
288 key={choice.mode ?? "inherit"}
289 label={choice.label}
290 description={choice.description}
291 selected={
292 !isMixed("defaultThreadEnvMode") &&
293 uniform("defaultThreadEnvMode") === choice.mode
294 }
295 separated={index > 0}
296 disabled={disabledFor("defaultThreadEnvMode")}
297 onPress={() => write({ defaultThreadEnvMode: choice.mode })}
298 />
299 ))}
300 </SettingsSection>
301 <SettingsSection
302 title="Worktree submodules"
303 trailing={
304 pendingWrites === 0 && isMixed("worktreeSubmodules") ? (
305 <MixedValuesLabel projectSelected={projectSelected} />
306 ) : null
307 }
308 >
309 {SUBMODULE_CHOICES.filter(
310 (choice) => choice.mode !== null || !projectSelected,
311 ).map((choice, index) => (
312 <SettingsChoiceRow
313 key={choice.mode ?? "inherit"}
314 label={choice.label}
315 description={choice.description}
316 selected={
317 !isMixed("worktreeSubmodules") &&
318 uniform("worktreeSubmodules") === choice.mode
319 }
320 separated={index > 0}
321 disabled={disabledFor("worktreeSubmodules")}
322 onPress={() => write({ worktreeSubmodules: choice.mode })}
323 />
324 ))}
325 </SettingsSection>
326 <SettingsSection
327 title="Default permissions"
328 trailing={
329 pendingWrites === 0 && uniform("defaultRuntimeMode") === null ? (
330 <MixedValuesLabel projectSelected={projectSelected} />
331 ) : null
332 }
333 >
334 {RUNTIME_MODE_CHOICES.map((choice, index) => (
335 <SettingsChoiceRow
336 key={choice.mode}
337 label={choice.label}
338 description={choice.description}
339 selected={uniform("defaultRuntimeMode") === choice.mode}
340 separated={index > 0}
341 disabled={disabledFor("defaultRuntimeMode")}
342 onPress={() => write({ defaultRuntimeMode: choice.mode })}
343 />
344 ))}
345 </SettingsSection>
346 </>
347 ) : null}
348
349 {props.page === "source-control" ? (
350 <>
351 <BranchNamingSettings
352 key={targets
353 .map((target) => `${target.environment.environmentId}:${target.projectId}`)
354 .join(",")}
355 mode={uniform("branchNamingMode")}
356 prefix={uniform("branchNamePrefix")}
357 instructions={uniform("branchNameInstructions")}
358 disabled={disabledFor("branchNamingMode")}
359 onChange={write}
360 />
361 <SettingsSection title="Pull requests">
362 <SettingsSwitchRow
363 icon="arrow.triangle.merge"
364 label="Remove agent credits when merging"
365 subtitle="Remove recognized agent credits from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits."
366 value={uniform("removeAgentCreditsOnMerge")}
367 disabled={disabledFor("removeAgentCreditsOnMerge")}
368 onValueChange={(value) => write({ removeAgentCreditsOnMerge: value })}
369 />
370 </SettingsSection>
371 <SettingsSection title="Default branch">
372 <SettingsSwitchRow
373 icon="arrow.down.circle"
374 label="Automatically pull"
375 subtitle="Keep the default branch current when there are no local changes."
376 value={uniform("defaultAutoPull")}
377 disabled={disabledFor("defaultAutoPull")}
378 onValueChange={(value) => write({ defaultAutoPull: value })}
379 />
380 </SettingsSection>
381 <SettingsSection title="Worktrees">
382 <SettingsSwitchRow
383 icon="arrow.triangle.branch"
384 label="Start from origin"
385 subtitle="Base new worktrees on the remote branch."
386 value={uniform("newWorktreesStartFromOrigin")}
387 disabled={disabledFor("newWorktreesStartFromOrigin")}
388 onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })}
389 />
390 </SettingsSection>
391 </>
392 ) : null}
393
394 {props.page === "agent-behavior" ? (
395 <>
396 <SettingsSection
397 title="Response streaming"
398 trailing={
399 pendingWrites === 0 && uniform("responseStreamingMode") === null ? (
400 <MixedValuesLabel projectSelected={projectSelected} />
401 ) : null
402 }
403 >
404 {STREAMING_CHOICES.map((choice, index) => (
405 <SettingsChoiceRow
406 key={choice.mode}
407 label={choice.label}
408 description={choice.description}
409 selected={uniform("responseStreamingMode") === choice.mode}
410 separated={index > 0}
411 disabled={disabledFor("responseStreamingMode")}
412 onPress={() => write({ responseStreamingMode: choice.mode })}
413 />
414 ))}
415 </SettingsSection>
416 <SettingsSection title="Preview browser">
417 <SettingsSwitchRow
418 icon="globe"
419 label="Agent browser access"
420 subtitle="Allow agents to use the in-app preview browser."
421 value={uniform("enableAgentBrowserAccess")}
422 disabled={disabledFor("enableAgentBrowserAccess")}
423 onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
424 />
425 </SettingsSection>
426 </>
427 ) : null}
428
429 {props.page === "maintenance" ? (
430 <>
431 {!projectSelected ? (
432 <SettingsSection title="Manage environments">
433 {selectedTargets.map((target) => (
434 <SettingsRow
435 key={target.environmentId}
436 icon="server.rack"
437 label={target.label}
438 value="Server and provider updates"
439 onPress={() =>
440 navigation.navigate("SettingsSheet", {
441 screen: "SettingsContent",
442 params: {
443 screen: "SettingsEnvironmentDetail",
444 params: { environmentId: target.environmentId },
445 },
446 })
447 }
448 />
449 ))}
450 </SettingsSection>
451 ) : null}
452 <SettingsSection title="Updates">
453 <SettingsSwitchRow
454 icon="arrow.clockwise"
455 label="Check provider updates"
456 subtitle={
457 projectSelected
458 ? "Environment-wide setting. Select All projects to change it."
459 : "Check installed provider CLIs for newer versions and fetch model catalog updates. When off, new models may not appear."
460 }
461 value={uniform("enableProviderUpdateChecks")}
462 disabled={disabledFor("enableProviderUpdateChecks")}
463 onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
464 />
465 <View className="border-t border-border-subtle">
466 <SettingsSwitchRow
467 icon="arrow.uturn.forward"
468 label="Continue after restart"
469 subtitle={
470 supportsContinuation
471 ? "Resume interrupted threads after an update or restart."
472 : "Update older servers to control restart continuation."
473 }
474 value={uniform("continueThreadsAfterServerUpdate")}
475 disabled={
476 disabledFor("continueThreadsAfterServerUpdate") || !supportsContinuation
477 }
478 onValueChange={(value) =>
479 write({ continueThreadsAfterServerUpdate: value })
480 }
481 />
482 </View>
483 </SettingsSection>
484 </>
485 ) : null}
486 </>
487 )}
488 </ScrollView>
489 </SettingsScreen>
490 </>
491 );
492}
493
494function MixedValuesLabel(props: { readonly projectSelected: boolean }) {
495 return (
496 <Text
497 accessibilityLabel={
498 props.projectSelected
499 ? "Selected project checkouts use different values"
500 : "Selected environments use different values"
501 }
502 className="px-2 text-sm text-foreground-muted android:px-4"
503 >
504 Mixed
505 </Text>
506 );
507}