| 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import { SettingsRow } from "./components/SettingsRow"; |
| 3 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 4 | import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; |
| 5 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { |
| 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"; |
| 16 | import { useRef, useState } from "react"; |
| 17 | import { View } from "react-native"; |
| 18 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 19 | |
| 20 | import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options"; |
| 21 | import { serverEnvironment } from "../../state/server"; |
| 22 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 23 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 24 | import { |
| 25 | AndroidSettingsEnvironmentFilter, |
| 26 | SettingsEnvironmentFilterHeader, |
| 27 | } from "./components/SettingsEnvironmentFilterHeader"; |
| 28 | import { BranchNamingSettings } from "./components/BranchNamingSettings"; |
| 29 | import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; |
| 30 | import { SettingsSection } from "./components/SettingsSection"; |
| 31 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 32 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; |
| 33 | import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; |
| 34 | import { |
| 35 | planMobileScopedSettingsClear, |
| 36 | planMobileScopedSettingsPatch, |
| 37 | resolveMobileSettingsTargets, |
| 38 | uniformMobileSetting, |
| 39 | type ScopedMobileSettingsTarget, |
| 40 | } from "./settings-scoped-server"; |
| 41 | |
| 42 | type SettingsPage = "new-threads" | "source-control" | "agent-behavior" | "maintenance"; |
| 43 | |
| 44 | const 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 | |
| 51 | const 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 | |
| 65 | const 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 | |
| 85 | const 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 | |
| 108 | const 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 | |
| 125 | export function SettingsEnvironmentNewThreadsRouteScreen() { |
| 126 | return <ServerSettingsDetail page="new-threads" />; |
| 127 | } |
| 128 | |
| 129 | export function SettingsEnvironmentSourceControlRouteScreen() { |
| 130 | return <ServerSettingsDetail page="source-control" />; |
| 131 | } |
| 132 | |
| 133 | export function SettingsEnvironmentAgentBehaviorRouteScreen() { |
| 134 | return <ServerSettingsDetail page="agent-behavior" />; |
| 135 | } |
| 136 | |
| 137 | export function SettingsEnvironmentMaintenanceRouteScreen() { |
| 138 | return <ServerSettingsDetail page="maintenance" />; |
| 139 | } |
| 140 | |
| 141 | function 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 | |
| 494 | function 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 | } |