Unified · Split
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | squashAtomCommandFailure, |
| 20 | 20 | } from "@t3tools/client-runtime/state/runtime"; |
| 21 | 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 22 | −import { AppText as Text } from "../../components/AppText"; | |
| 22 | +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | |
| 23 | 23 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; |
| 24 | 24 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; |
| 25 | 25 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; |
| @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; · 10 unmodified lines | ||
| 36 | 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 37 | 37 | import { serverEnvironment } from "../../state/server"; |
| 38 | 38 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 39 | −import type { EnvironmentId } from "@t3tools/contracts"; | |
| 39 | +import { useEnvironments } from "../../state/environments"; | |
| 40 | +import { | |
| 41 | + DEFAULT_SERVER_SETTINGS, | |
| 42 | + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 43 | + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | |
| 44 | + type ServerSettingsPatch, | |
| 45 | +} from "@t3tools/contracts"; | |
| 46 | +import { | |
| 47 | + findSharedSettingsMismatches, | |
| 48 | + pickSharedServerSettings, | |
| 49 | +} from "@t3tools/client-runtime/state/shared-settings"; | |
| 40 | 50 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | 51 | import { |
| 42 | 52 | type AppUpdateCheckState, |
| @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { · 487 unmodified lines | ||
| 530 | 540 | } |
| 531 | 541 | |
| 532 | 542 | function GeneralSettingsSection() { |
| 533 | − const { savedConnectionsById } = useSavedRemoteConnections(); | |
| 534 | − const connections = Object.values(savedConnectionsById).sort((left, right) => | |
| 535 | − left.environmentLabel.localeCompare(right.environmentLabel), | |
| 536 | − ); | |
| 537 | − | |
| 538 | 543 | return ( |
| 539 | 544 | <SettingsSection title="General"> |
| 540 | 545 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 541 | − {connections.map((connection) => ( | |
| 542 | − <EnvironmentAutoSettleSwitch | |
| 543 | − key={connection.environmentId} | |
| 544 | − environmentId={connection.environmentId} | |
| 545 | − environmentLabel={connection.environmentLabel} | |
| 546 | − /> | |
| 547 | − ))} | |
| 546 | + <AutoSettleSettingsRows /> | |
| 548 | 547 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 549 | 548 | </SettingsSection> |
| 550 | 549 | ); |
| 551 | 550 | } |
| 552 | 551 | |
| 553 | −function EnvironmentAutoSettleSwitch(props: { | |
| 554 | − readonly environmentId: EnvironmentId; | |
| 555 | − readonly environmentLabel: string; | |
| 556 | −}) { | |
| 557 | − const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); | |
| 558 | − const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); | |
| 552 | +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | |
| 553 | + | |
| 554 | +/** | |
| 555 | + * Auto-settlement is a user preference that every server has to hold. Mobile | |
| 556 | + * has no primary environment, so the first connected environment that | |
| 557 | + * supports it is the reference value. Edits fan out to every connected | |
| 558 | + * environment, and a mismatch row lets the user push the reference out. | |
| 559 | + */ | |
| 560 | +function AutoSettleSettingsRows() { | |
| 561 | + const { environments } = useEnvironments(); | |
| 559 | 562 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 560 | − label: "auto-settle settings update", | |
| 563 | + label: "server settings update", | |
| 561 | 564 | reportFailure: true, |
| 562 | 565 | }); |
| 563 | − if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { | |
| 566 | + | |
| 567 | + const connected = environments.filter( | |
| 568 | + (environment) => | |
| 569 | + environment.connection.phase === "connected" && | |
| 570 | + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, | |
| 571 | + ); | |
| 572 | + const reference = connected[0] ?? null; | |
| 573 | + const referenceSettings = reference?.serverConfig?.settings ?? null; | |
| 574 | + | |
| 575 | + const [daysDraft, setDaysDraft] = useState<string | null>(null); | |
| 576 | + | |
| 577 | + if (reference === null || referenceSettings === null) { | |
| 564 | 578 | return null; |
| 565 | 579 | } |
| 580 | + | |
| 581 | + const writeToAll = (patch: ServerSettingsPatch) => { | |
| 582 | + for (const environment of connected) { | |
| 583 | + void updateSettings({ environmentId: environment.environmentId, input: { patch } }); | |
| 584 | + } | |
| 585 | + }; | |
| 586 | + | |
| 587 | + const mismatches = findSharedSettingsMismatches({ | |
| 588 | + primaryEnvironmentId: reference.environmentId, | |
| 589 | + primarySettings: referenceSettings, | |
| 590 | + environments: environments.map((environment) => ({ | |
| 591 | + environmentId: environment.environmentId, | |
| 592 | + label: environment.label, | |
| 593 | + connected: environment.connection.phase === "connected", | |
| 594 | + settings: environment.serverConfig?.settings ?? null, | |
| 595 | + })), | |
| 596 | + }); | |
| 597 | + | |
| 598 | + const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | |
| 599 | + const commitDays = () => { | |
| 600 | + const draft = (daysDraft ?? "").trim(); | |
| 601 | + setDaysDraft(null); | |
| 602 | + // Whole-string check so "3.5" and "3days" are rejected instead of | |
| 603 | + // silently becoming 3 on every connected environment. | |
| 604 | + const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; | |
| 605 | + if ( | |
| 606 | + Number.isInteger(parsed) && | |
| 607 | + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 608 | + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | |
| 609 | + parsed !== afterDays | |
| 610 | + ) { | |
| 611 | + writeToAll({ sidebarAutoSettleAfterDays: parsed }); | |
| 612 | + } | |
| 613 | + }; | |
| 614 | + | |
| 566 | 615 | return ( |
| 567 | − <SettingsSwitchRow | |
| 568 | − icon="arrow.triangle.branch" | |
| 569 | − label={`Auto-settle merged threads · ${props.environmentLabel}`} | |
| 570 | − value={settings?.sidebarAutoSettleOnMerge ?? true} | |
| 571 | − onValueChange={(value) => { | |
| 572 | − void updateSettings({ | |
| 573 | − environmentId: props.environmentId, | |
| 574 | − input: { patch: { sidebarAutoSettleOnMerge: value } }, | |
| 575 | − }); | |
| 576 | − }} | |
| 577 | − /> | |
| 616 | + <> | |
| 617 | + <SettingsSwitchRow | |
| 618 | + icon="arrow.triangle.branch" | |
| 619 | + label="Auto-settle merged threads" | |
| 620 | + value={referenceSettings.sidebarAutoSettleOnMerge} | |
| 621 | + onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} | |
| 622 | + /> | |
| 623 | + <SettingsSwitchRow | |
| 624 | + icon="clock" | |
| 625 | + label="Auto-settle inactive threads" | |
| 626 | + subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`} | |
| 627 | + value={afterDays !== null} | |
| 628 | + onValueChange={(value) => | |
| 629 | + writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) | |
| 630 | + } | |
| 631 | + /> | |
| 632 | + {afterDays !== null ? ( | |
| 633 | + <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | |
| 634 | + <Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text> | |
| 635 | + <TextInput | |
| 636 | + className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base" | |
| 637 | + keyboardType="number-pad" | |
| 638 | + returnKeyType="done" | |
| 639 | + value={daysDraft ?? String(afterDays)} | |
| 640 | + onChangeText={setDaysDraft} | |
| 641 | + onBlur={commitDays} | |
| 642 | + onSubmitEditing={commitDays} | |
| 643 | + accessibilityLabel="Days before auto-settle" | |
| 644 | + /> | |
| 645 | + </View> | |
| 646 | + ) : null} | |
| 647 | + {mismatches.length > 0 ? ( | |
| 648 | + <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | |
| 649 | + <View className="min-w-0 flex-1"> | |
| 650 | + <Text className="text-lg text-foreground">Settings differ</Text> | |
| 651 | + <Text className="text-sm text-foreground-muted"> | |
| 652 | + {mismatches.map((mismatch) => mismatch.label).join(", ")} | |
| 653 | + </Text> | |
| 654 | + </View> | |
| 655 | + <Pressable | |
| 656 | + accessibilityRole="button" | |
| 657 | + onPress={() => { | |
| 658 | + const patch = pickSharedServerSettings(referenceSettings); | |
| 659 | + for (const mismatch of mismatches) { | |
| 660 | + void updateSettings({ | |
| 661 | + environmentId: mismatch.environmentId, | |
| 662 | + input: { patch }, | |
| 663 | + }); | |
| 664 | + } | |
| 665 | + }} | |
| 666 | + className="rounded-full bg-subtle px-4 py-2 active:opacity-70" | |
| 667 | + > | |
| 668 | + <Text className="text-base font-t3-medium text-foreground">Apply to all</Text> | |
| 669 | + </Pressable> | |
| 670 | + </View> | |
| 671 | + ) : null} | |
| 672 | + </> | |
| 578 | 673 | ); |
| 579 | 674 | } |
| 580 | 675 | |
| @@ -112,6 +112,7 @@ import { · 111 unmodified lines | ||
| 112 | 112 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 113 | 113 | } from "../../appearanceFonts"; |
| 114 | 114 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 115 | +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | |
| 115 | 116 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; |
| 116 | 117 | import { |
| 117 | 118 | NumberField, |
| @@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { · 1810 unmodified lines | ||
| 1928 | 1929 | |
| 1929 | 1930 | return ( |
| 1930 | 1931 | <SettingsPageContainer> |
| 1932 | + <SharedSettingsMismatchAlert /> | |
| 1931 | 1933 | <SettingsSection title="General"> |
| 1932 | 1934 | <SettingsRow |
| 1933 | 1935 | {...searchableSetting("project-grouping")} |
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { TriangleAlertIcon } from "lucide-react"; | |
| 2 | + | |
| 3 | +import { useSharedSettingsSync } from "../../hooks/useSettings"; | |
| 4 | +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | |
| 5 | +import { Button } from "../ui/button"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Warns when a connected environment holds different shared settings than | |
| 9 | + * the primary one, and offers to write the primary's values everywhere. | |
| 10 | + * Renders nothing when every connected environment agrees. | |
| 11 | + */ | |
| 12 | +export function SharedSettingsMismatchAlert() { | |
| 13 | + const { mismatches, applyToAll } = useSharedSettingsSync(); | |
| 14 | + if (mismatches.length === 0) { | |
| 15 | + return null; | |
| 16 | + } | |
| 17 | + const labels = mismatches.map((mismatch) => mismatch.label).join(", "); | |
| 18 | + return ( | |
| 19 | + <Alert variant="warning" className="mx-3 sm:mx-4"> | |
| 20 | + <TriangleAlertIcon /> | |
| 21 | + <AlertDescription> | |
| 22 | + Settings differ on {labels}. Thread and source control preferences are meant to match on | |
| 23 | + every environment. | |
| 24 | + </AlertDescription> | |
| 25 | + <AlertAction> | |
| 26 | + <Button variant="outline" size="compact" onClick={applyToAll}> | |
| 27 | + Apply to all | |
| 28 | + </Button> | |
| 29 | + </AlertAction> | |
| 30 | + </Alert> | |
| 31 | + ); | |
| 32 | +} | |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 19 | 19 | |
| 20 | 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 21 | +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | |
| 21 | 22 | import { cn } from "../../lib/utils"; |
| 22 | 23 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| 23 | 24 | import { useEnvironmentQuery } from "../../state/query"; |
| @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { · 524 unmodified lines | ||
| 548 | 549 | |
| 549 | 550 | return ( |
| 550 | 551 | <SettingsPageContainer> |
| 552 | + <SharedSettingsMismatchAlert /> | |
| 551 | 553 | {isInitialScanPending ? ( |
| 552 | 554 | <> |
| 553 | 555 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> |
| @@ -25,6 +25,11 @@ import { · 24 unmodified lines | ||
| 25 | 25 | type UnifiedSettings, |
| 26 | 26 | } from "@t3tools/contracts/settings"; |
| 27 | 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 28 | +import { | |
| 29 | + findSharedSettingsMismatches, | |
| 30 | + pickSharedServerSettings, | |
| 31 | + splitSharedServerPatch, | |
| 32 | +} from "@t3tools/client-runtime/state/shared-settings"; | |
| 28 | 33 | import { ensureLocalApi } from "~/localApi"; |
| 29 | 34 | import { |
| 30 | 35 | getThemeDefinition, |
| @@ -35,7 +40,11 @@ import { · 4 unmodified lines | ||
| 35 | 40 | } from "~/themePalette"; |
| 36 | 41 | import * as Struct from "effect/Struct"; |
| 37 | 42 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 38 | −import { usePrimaryEnvironment } from "~/state/environments"; | |
| 43 | +import { | |
| 44 | + type EnvironmentPresentation, | |
| 45 | + useEnvironments, | |
| 46 | + usePrimaryEnvironment, | |
| 47 | +} from "~/state/environments"; | |
| 39 | 48 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 40 | 49 | import { useTheme } from "./useTheme"; |
| 41 | 50 | |
| @@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>( · 265 unmodified lines | ||
| 307 | 316 | return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); |
| 308 | 317 | } |
| 309 | 318 | |
| 319 | +/** | |
| 320 | + * Whether an environment can hold every shared key right now. Gated on the | |
| 321 | + * auto-settlement capability because it is the newest of the shared keys: a | |
| 322 | + * server that has it has all of them. Older servers drop unknown keys on | |
| 323 | + * write, so a mismatch against them could never clear, and their decoded | |
| 324 | + * defaults must not be treated as real values. | |
| 325 | + */ | |
| 326 | +function supportsSharedSettings(environment: EnvironmentPresentation): boolean { | |
| 327 | + return ( | |
| 328 | + environment.connection.phase === "connected" && | |
| 329 | + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true | |
| 330 | + ); | |
| 331 | +} | |
| 332 | + | |
| 333 | +/** Environments that can receive a shared settings write right now. */ | |
| 334 | +function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> { | |
| 335 | + const { environments } = useEnvironments(); | |
| 336 | + return useMemo( | |
| 337 | + () => | |
| 338 | + environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), | |
| 339 | + [environments], | |
| 340 | + ); | |
| 341 | +} | |
| 342 | + | |
| 310 | 343 | /** |
| 311 | 344 | * Returns an updater that routes each key to the correct backing store. |
| 312 | 345 | * |
| 313 | 346 | * Server keys are optimistically patched in atom-backed server state, then |
| 314 | − * persisted via RPC. Client keys go through client persistence. | |
| 347 | + * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) | |
| 348 | + * are written to every connected environment, not only the target, so a user | |
| 349 | + * preference does not silently drift between machines. Client keys go through | |
| 350 | + * client persistence. | |
| 315 | 351 | */ |
| 316 | 352 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 317 | 353 | const persistServerSettings = useAtomCommand( |
| 318 | 354 | serverEnvironment.updateSettings, |
| 319 | 355 | "server settings update", |
| 320 | 356 | ); |
| 357 | + const connectedEnvironmentIds = useConnectedEnvironmentIds(); | |
| 321 | 358 | const updateSettings = useCallback( |
| 322 | 359 | (patch: UnifiedSettingsPatch) => { |
| 323 | 360 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 324 | 361 | |
| 325 | 362 | if (Object.keys(serverPatch).length > 0) { |
| 326 | − if (environmentId) { | |
| 363 | + const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); | |
| 364 | + if (environmentId && Object.keys(localPatch).length > 0) { | |
| 327 | 365 | void persistServerSettings({ |
| 328 | 366 | environmentId, |
| 329 | − input: { patch: serverPatch }, | |
| 367 | + input: { patch: localPatch }, | |
| 330 | 368 | }); |
| 331 | 369 | } |
| 370 | + if (Object.keys(sharedPatch).length > 0) { | |
| 371 | + const targets = new Set(connectedEnvironmentIds); | |
| 372 | + if (environmentId) { | |
| 373 | + targets.add(environmentId); | |
| 374 | + } | |
| 375 | + for (const targetId of targets) { | |
| 376 | + void persistServerSettings({ | |
| 377 | + environmentId: targetId, | |
| 378 | + input: { patch: sharedPatch }, | |
| 379 | + }); | |
| 380 | + } | |
| 381 | + } | |
| 332 | 382 | } |
| 333 | 383 | if (Object.keys(clientPatch).length > 0) { |
| 334 | 384 | persistClientSettings({ |
| @@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { · 2 unmodified lines | ||
| 337 | 387 | }); |
| 338 | 388 | } |
| 339 | 389 | }, |
| 340 | − [environmentId, persistServerSettings], | |
| 390 | + [connectedEnvironmentIds, environmentId, persistServerSettings], | |
| 341 | 391 | ); |
| 342 | 392 | |
| 343 | 393 | return updateSettings; |
| 344 | 394 | } |
| 345 | 395 | |
| 396 | +/** | |
| 397 | + * Connected environments whose shared settings differ from the primary's, | |
| 398 | + * plus an action that writes the primary's values to all of them. Drift | |
| 399 | + * happens when an environment was offline during an edit or was changed by | |
| 400 | + * an older client. | |
| 401 | + */ | |
| 402 | +export function useSharedSettingsSync() { | |
| 403 | + const primaryEnvironment = usePrimaryEnvironment(); | |
| 404 | + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | |
| 405 | + // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls | |
| 406 | + // back to defaults while the primary is disconnected, and "apply to all" | |
| 407 | + // must never push defaults over real values. Same for a primary too old to | |
| 408 | + // hold the shared keys: its decoded defaults are not a source of truth. | |
| 409 | + const primarySettings = | |
| 410 | + primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) | |
| 411 | + ? (primaryEnvironment.serverConfig?.settings ?? null) | |
| 412 | + : null; | |
| 413 | + const { environments } = useEnvironments(); | |
| 414 | + const persistServerSettings = useAtomCommand( | |
| 415 | + serverEnvironment.updateSettings, | |
| 416 | + "server settings update", | |
| 417 | + ); | |
| 418 | + | |
| 419 | + const mismatches = useMemo( | |
| 420 | + () => | |
| 421 | + findSharedSettingsMismatches({ | |
| 422 | + primaryEnvironmentId, | |
| 423 | + primarySettings, | |
| 424 | + environments: environments.map((environment) => ({ | |
| 425 | + environmentId: environment.environmentId, | |
| 426 | + label: environment.label, | |
| 427 | + connected: supportsSharedSettings(environment), | |
| 428 | + settings: environment.serverConfig?.settings ?? null, | |
| 429 | + })), | |
| 430 | + }), | |
| 431 | + [environments, primaryEnvironmentId, primarySettings], | |
| 432 | + ); | |
| 433 | + | |
| 434 | + const applyToAll = useCallback(() => { | |
| 435 | + if (primarySettings === null) { | |
| 436 | + return; | |
| 437 | + } | |
| 438 | + const patch = pickSharedServerSettings(primarySettings); | |
| 439 | + for (const mismatch of mismatches) { | |
| 440 | + void persistServerSettings({ | |
| 441 | + environmentId: mismatch.environmentId, | |
| 442 | + input: { patch }, | |
| 443 | + }); | |
| 444 | + } | |
| 445 | + }, [mismatches, persistServerSettings, primarySettings]); | |
| 446 | + | |
| 447 | + return { mismatches, applyToAll }; | |
| 448 | +} | |
| 449 | + | |
| 346 | 450 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { |
| 347 | 451 | return useUpdateSettingsTarget(environmentId); |
| 348 | 452 | } |
| @@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by · 87 unmodified lines | ||
| 88 | 88 | `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later |
| 89 | 89 | does not define turn end. |
| 90 | 90 | |
| 91 | −Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. | |
| 91 | +Thread settlement is server-owned. Each server's own settings control PR and inactivity | |
| 92 | +settlement. Those keys are user preferences, so clients write them to every connected environment | |
| 93 | +(`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn | |
| 94 | +when a connected environment drifts. | |
| 92 | 95 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and |
| 93 | 96 | once per minute, including when no client is connected. It dispatches the guarded internal |
| 94 | 97 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic |
| @@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p · 9 unmodified lines | ||
| 10 | 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 11 | 11 | request merges if **Auto-settle merged threads** is enabled. |
| 12 | 12 | |
| 13 | −Each environment owns its automatic settlement settings. The server checks them even when no web, | |
| 14 | −desktop, or mobile client is connected. By default, it settles threads after three days without | |
| 13 | +Each server stores its own copy of the automatic settlement settings and checks them even when no | |
| 14 | +web, desktop, or mobile client is connected. By default, it settles threads after three days without | |
| 15 | 15 | activity and when their pull request merges. An eligible idle thread also settles when its pull |
| 16 | 16 | request closes. An open pull request blocks inactivity settlement. Active work, pending input, and |
| 17 | 17 | live background work keep the thread active. T3 Code settles from a closed or merged pull request |
| 18 | 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not |
| 19 | 19 | available, the inactivity rule still applies. A manual un-settle also keeps the thread active. |
| 20 | −Change these rules in **Settings > General** for the environment. A settings change affects future | |
| 21 | −settlement and does not reopen a settled thread. Settings saved by older clients on one device no | |
| 22 | −longer control this behavior. | |
| 20 | + | |
| 21 | +Change these rules in **Settings > General**. The change is written to every environment you are | |
| 22 | +connected to at that moment. An environment that is offline keeps its old value. When a connected | |
| 23 | +environment holds a different value, **Settings > General** shows a warning that names it. Choose | |
| 24 | +**Apply to all** to write your current values to every connected environment. The same applies to | |
| 25 | +the new-thread workspace mode and the source control writing style. | |
| 26 | + | |
| 27 | +A settings change affects future settlement and does not reopen a settled thread. Settings saved | |
| 28 | +by older clients on one device no longer control this behavior. | |
| 23 | 29 | |
| 24 | 30 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 25 | 31 | away. Its timestamps do not change. Other threads keep their positions. |
| @@ -147,6 +147,10 @@ · 146 unmodified lines | ||
| 147 | 147 | "types": "./src/state/session.ts", |
| 148 | 148 | "default": "./src/state/session.ts" |
| 149 | 149 | }, |
| 150 | + "./state/shared-settings": { | |
| 151 | + "types": "./src/state/sharedSettings.ts", | |
| 152 | + "default": "./src/state/sharedSettings.ts" | |
| 153 | + }, | |
| 150 | 154 | "./state/shell": { |
| 151 | 155 | "types": "./src/state/shell.ts", |
| 152 | 156 | "default": "./src/state/shell.ts" |
| @@ -0,0 +1,107 @@ | ||
| 1 | +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + findSharedSettingsMismatches, | |
| 6 | + pickSharedServerSettings, | |
| 7 | + splitSharedServerPatch, | |
| 8 | +} from "./sharedSettings.ts"; | |
| 9 | + | |
| 10 | +const primaryId = EnvironmentId.make("env-primary"); | |
| 11 | +const laptopId = EnvironmentId.make("env-laptop"); | |
| 12 | +const boxId = EnvironmentId.make("env-box"); | |
| 13 | + | |
| 14 | +describe("splitSharedServerPatch", () => { | |
| 15 | + it("routes preference keys to the shared patch and machine keys to the local patch", () => { | |
| 16 | + const { sharedPatch, localPatch } = splitSharedServerPatch({ | |
| 17 | + sidebarAutoSettleAfterDays: 7, | |
| 18 | + sidebarAutoSettleOnMerge: false, | |
| 19 | + enableAgentBrowserAccess: false, | |
| 20 | + }); | |
| 21 | + expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); | |
| 22 | + expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); | |
| 23 | + }); | |
| 24 | +}); | |
| 25 | + | |
| 26 | +describe("pickSharedServerSettings", () => { | |
| 27 | + it("returns only the shared keys", () => { | |
| 28 | + expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ | |
| 29 | + "defaultThreadEnvMode", | |
| 30 | + "newWorktreesStartFromOrigin", | |
| 31 | + "sidebarAutoSettleAfterDays", | |
| 32 | + "sidebarAutoSettleOnMerge", | |
| 33 | + "sourceControlWritingStyle", | |
| 34 | + ]); | |
| 35 | + }); | |
| 36 | +}); | |
| 37 | + | |
| 38 | +describe("findSharedSettingsMismatches", () => { | |
| 39 | + const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; | |
| 40 | + | |
| 41 | + it("lists connected environments whose shared settings differ", () => { | |
| 42 | + const mismatches = findSharedSettingsMismatches({ | |
| 43 | + primaryEnvironmentId: primaryId, | |
| 44 | + primarySettings, | |
| 45 | + environments: [ | |
| 46 | + { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, | |
| 47 | + { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, | |
| 48 | + { | |
| 49 | + environmentId: boxId, | |
| 50 | + label: "Remote Box", | |
| 51 | + connected: true, | |
| 52 | + settings: DEFAULT_SERVER_SETTINGS, | |
| 53 | + }, | |
| 54 | + ], | |
| 55 | + }); | |
| 56 | + expect(mismatches).toEqual([{ environmentId: boxId, label: "Remote Box" }]); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("ignores machine-only differences", () => { | |
| 60 | + const mismatches = findSharedSettingsMismatches({ | |
| 61 | + primaryEnvironmentId: primaryId, | |
| 62 | + primarySettings, | |
| 63 | + environments: [ | |
| 64 | + { | |
| 65 | + environmentId: boxId, | |
| 66 | + label: "Remote Box", | |
| 67 | + connected: true, | |
| 68 | + settings: { ...primarySettings, enableAgentBrowserAccess: false }, | |
| 69 | + }, | |
| 70 | + ], | |
| 71 | + }); | |
| 72 | + expect(mismatches).toEqual([]); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("reports nothing until the primary environment's settings are loaded", () => { | |
| 76 | + const environments = [ | |
| 77 | + { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, | |
| 78 | + ]; | |
| 79 | + expect( | |
| 80 | + findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), | |
| 81 | + ).toEqual([]); | |
| 82 | + expect( | |
| 83 | + findSharedSettingsMismatches({ | |
| 84 | + primaryEnvironmentId: primaryId, | |
| 85 | + primarySettings: null, | |
| 86 | + environments, | |
| 87 | + }), | |
| 88 | + ).toEqual([]); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("skips offline environments and environments without a loaded config", () => { | |
| 92 | + const mismatches = findSharedSettingsMismatches({ | |
| 93 | + primaryEnvironmentId: primaryId, | |
| 94 | + primarySettings, | |
| 95 | + environments: [ | |
| 96 | + { | |
| 97 | + environmentId: laptopId, | |
| 98 | + label: "Laptop", | |
| 99 | + connected: false, | |
| 100 | + settings: DEFAULT_SERVER_SETTINGS, | |
| 101 | + }, | |
| 102 | + { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, | |
| 103 | + ], | |
| 104 | + }); | |
| 105 | + expect(mismatches).toEqual([]); | |
| 106 | + }); | |
| 107 | +}); | |
| @@ -0,0 +1,90 @@ | ||
| 1 | +/** | |
| 2 | + * Shared server settings. | |
| 3 | + * | |
| 4 | + * Every server keeps its own `settings.json`, but some keys are user | |
| 5 | + * preferences that only live on the server because the server has to act on | |
| 6 | + * them (auto-settlement runs with no client attached). A user does not want | |
| 7 | + * those to differ per machine. Clients write these keys to every connected | |
| 8 | + * environment, and warn when a connected environment still holds a different | |
| 9 | + * value so the user can push their current value out. | |
| 10 | + */ | |
| 11 | +import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | |
| 12 | +import * as Equal from "effect/Equal"; | |
| 13 | +import * as Struct from "effect/Struct"; | |
| 14 | + | |
| 15 | +/** Server keys that hold a user preference rather than machine config. */ | |
| 16 | +export const SHARED_SERVER_SETTING_KEYS = [ | |
| 17 | + "sidebarAutoSettleAfterDays", | |
| 18 | + "sidebarAutoSettleOnMerge", | |
| 19 | + "defaultThreadEnvMode", | |
| 20 | + "newWorktreesStartFromOrigin", | |
| 21 | + "sourceControlWritingStyle", | |
| 22 | +] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>; | |
| 23 | + | |
| 24 | +export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; | |
| 25 | + | |
| 26 | +const SHARED_KEY_SET = new Set<string>(SHARED_SERVER_SETTING_KEYS); | |
| 27 | + | |
| 28 | +/** Split a server patch into the keys every environment should receive and the primary-only rest. */ | |
| 29 | +export function splitSharedServerPatch(patch: ServerSettingsPatch): { | |
| 30 | + sharedPatch: ServerSettingsPatch; | |
| 31 | + localPatch: ServerSettingsPatch; | |
| 32 | +} { | |
| 33 | + const sharedPatch: Record<string, unknown> = {}; | |
| 34 | + const localPatch: Record<string, unknown> = {}; | |
| 35 | + for (const [key, value] of Object.entries(patch)) { | |
| 36 | + if (SHARED_KEY_SET.has(key)) { | |
| 37 | + sharedPatch[key] = value; | |
| 38 | + } else { | |
| 39 | + localPatch[key] = value; | |
| 40 | + } | |
| 41 | + } | |
| 42 | + return { | |
| 43 | + sharedPatch: sharedPatch as ServerSettingsPatch, | |
| 44 | + localPatch: localPatch as ServerSettingsPatch, | |
| 45 | + }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ | |
| 49 | +export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { | |
| 50 | + return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); | |
| 51 | +} | |
| 52 | + | |
| 53 | +export interface SharedSettingsEnvironment { | |
| 54 | + readonly environmentId: EnvironmentId; | |
| 55 | + readonly label: string; | |
| 56 | + readonly connected: boolean; | |
| 57 | + readonly settings: ServerSettings | null; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * Connected environments whose shared settings differ from the primary | |
| 62 | + * environment's. Offline environments are skipped: nothing can be read from | |
| 63 | + * or written to them, and the warning would never clear. With no primary | |
| 64 | + * settings loaded there is nothing to compare against, so nothing is | |
| 65 | + * reported. Callers must pass the real loaded settings, never a default | |
| 66 | + * fallback, or "apply to all" would push defaults over real values. | |
| 67 | + */ | |
| 68 | +export function findSharedSettingsMismatches(input: { | |
| 69 | + readonly primaryEnvironmentId: EnvironmentId | null; | |
| 70 | + readonly primarySettings: ServerSettings | null; | |
| 71 | + readonly environments: ReadonlyArray<SharedSettingsEnvironment>; | |
| 72 | +}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { | |
| 73 | + if (input.primaryEnvironmentId === null || input.primarySettings === null) { | |
| 74 | + return []; | |
| 75 | + } | |
| 76 | + const expected = pickSharedServerSettings(input.primarySettings); | |
| 77 | + return input.environments.flatMap((environment) => { | |
| 78 | + if ( | |
| 79 | + environment.environmentId === input.primaryEnvironmentId || | |
| 80 | + !environment.connected || | |
| 81 | + environment.settings === null | |
| 82 | + ) { | |
| 83 | + return []; | |
| 84 | + } | |
| 85 | + const actual = pickSharedServerSettings(environment.settings); | |
| 86 | + return Equal.equals(actual, expected) | |
| 87 | + ? [] | |
| 88 | + : [{ environmentId: environment.environmentId, label: environment.label }]; | |
| 89 | + }); | |
| 90 | +} | |