authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:18:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:18:20-07:00
log8efd4e95fcb08478c9c6e8eee384bfca559f62cc
treef1121414eebd262904d3b85629c160ab84de9bb4
parent5014e5fcdd6e5f4a53d0fea4cbecba733821a854
signature Signed by PGP key B5690EEEBB952194

fix(settings): sync auto-settle and other shared preferences across environments (#9147)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

10 files changed, 489 insertions(+), 44 deletions(-)

apps/mobile/src/features/settings/SettingsRouteScreen.tsx+128-33
......@@ -19,7 +19,7 @@ import {
1919 squashAtomCommandFailure,
2020} from "@t3tools/client-runtime/state/runtime";
2121import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22import { AppText as Text } from "../../components/AppText";
22import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
2323import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
2424import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
2525import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
......@@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime";
3636import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3737import { serverEnvironment } from "../../state/server";
3838import { useAtomCommand } from "../../state/use-atom-command";
39import type { EnvironmentId } from "@t3tools/contracts";
39import { useEnvironments } from "../../state/environments";
40import {
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";
46import {
47 findSharedSettingsMismatches,
48 pickSharedServerSettings,
49} from "@t3tools/client-runtime/state/shared-settings";
4050import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4151import {
4252 type AppUpdateCheckState,
......@@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() {
530540}
531541
532542function GeneralSettingsSection() {
533 const { savedConnectionsById } = useSavedRemoteConnections();
534 const connections = Object.values(savedConnectionsById).sort((left, right) =>
535 left.environmentLabel.localeCompare(right.environmentLabel),
536 );
537
538543 return (
539544 <SettingsSection title="General">
540545 <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 />
548547 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
549548 </SettingsSection>
550549 );
551550}
552551
553function 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));
552const 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 */
560function AutoSettleSettingsRows() {
561 const { environments } = useEnvironments();
559562 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
560 label: "auto-settle settings update",
563 label: "server settings update",
561564 reportFailure: true,
562565 });
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) {
564578 return null;
565579 }
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
566615 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 </>
578673 );
579674}
580675
apps/web/src/components/settings/SettingsPanels.tsx+2
......@@ -112,6 +112,7 @@ import {
112112 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
113113} from "../../appearanceFonts";
114114import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
115import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
115116import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
116117import {
117118 NumberField,
......@@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() {
19281929
19291930 return (
19301931 <SettingsPageContainer>
1932 <SharedSettingsMismatchAlert />
19311933 <SettingsSection title="General">
19321934 <SettingsRow
19331935 {...searchableSetting("project-grouping")}
apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx created+32
......@@ -0,0 +1,32 @@
1import { TriangleAlertIcon } from "lucide-react";
2
3import { useSharedSettingsSync } from "../../hooks/useSettings";
4import { Alert, AlertAction, AlertDescription } from "../ui/alert";
5import { 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 */
12export 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}
apps/web/src/components/settings/SourceControlSettings.tsx+2
......@@ -18,6 +18,7 @@ import {
1818} from "@t3tools/shared/backgroundActivitySettings";
1919
2020import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
21import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
2122import { cn } from "../../lib/utils";
2223import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
2324import { useEnvironmentQuery } from "../../state/query";
......@@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() {
548549
549550 return (
550551 <SettingsPageContainer>
552 <SharedSettingsMismatchAlert />
551553 {isInitialScanPending ? (
552554 <>
553555 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />
apps/web/src/hooks/useSettings.ts+109-5
......@@ -25,6 +25,11 @@ import {
2525 type UnifiedSettings,
2626} from "@t3tools/contracts/settings";
2727import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import {
29 findSharedSettingsMismatches,
30 pickSharedServerSettings,
31 splitSharedServerPatch,
32} from "@t3tools/client-runtime/state/shared-settings";
2833import { ensureLocalApi } from "~/localApi";
2934import {
3035 getThemeDefinition,
......@@ -35,7 +40,11 @@ import {
3540} from "~/themePalette";
3641import * as Struct from "effect/Struct";
3742import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
38import { usePrimaryEnvironment } from "~/state/environments";
43import {
44 type EnvironmentPresentation,
45 useEnvironments,
46 usePrimaryEnvironment,
47} from "~/state/environments";
3948import { useAtomCommand } from "~/state/use-atom-command";
4049import { useTheme } from "./useTheme";
4150
......@@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>(
307316 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);
308317}
309318
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 */
326function 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. */
334function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> {
335 const { environments } = useEnvironments();
336 return useMemo(
337 () =>
338 environments.filter(supportsSharedSettings).map((environment) => environment.environmentId),
339 [environments],
340 );
341}
342
310343/**
311344 * Returns an updater that routes each key to the correct backing store.
312345 *
313346 * 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.
315351 */
316352function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
317353 const persistServerSettings = useAtomCommand(
318354 serverEnvironment.updateSettings,
319355 "server settings update",
320356 );
357 const connectedEnvironmentIds = useConnectedEnvironmentIds();
321358 const updateSettings = useCallback(
322359 (patch: UnifiedSettingsPatch) => {
323360 const { serverPatch, clientPatch } = splitPatch(patch);
324361
325362 if (Object.keys(serverPatch).length > 0) {
326 if (environmentId) {
363 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
364 if (environmentId && Object.keys(localPatch).length > 0) {
327365 void persistServerSettings({
328366 environmentId,
329 input: { patch: serverPatch },
367 input: { patch: localPatch },
330368 });
331369 }
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 }
332382 }
333383 if (Object.keys(clientPatch).length > 0) {
334384 persistClientSettings({
......@@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
337387 });
338388 }
339389 },
340 [environmentId, persistServerSettings],
390 [connectedEnvironmentIds, environmentId, persistServerSettings],
341391 );
342392
343393 return updateSettings;
344394}
345395
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 */
402export 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
346450export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
347451 return useUpdateSettingsTarget(environmentId);
348452}
docs/internals/overview.md+4-1
......@@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by
8888`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
8989does not define turn end.
9090
91Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement.
91Thread settlement is server-owned. Each server's own settings control PR and inactivity
92settlement. 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
94when a connected environment drifts.
9295[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
9396once per minute, including when no client is connected. It dispatches the guarded internal
9497`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
docs/user/thread-sidebar.md+11-5
......@@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p
1010Pinned threads still move to **Settled** when they become inactive. They also move when their pull
1111request merges if **Auto-settle merged threads** is enabled.
1212
13Each environment owns its automatic settlement settings. The server checks them even when no web,
14desktop, or mobile client is connected. By default, it settles threads after three days without
13Each server stores its own copy of the automatic settlement settings and checks them even when no
14web, desktop, or mobile client is connected. By default, it settles threads after three days without
1515activity and when their pull request merges. An eligible idle thread also settles when its pull
1616request closes. An open pull request blocks inactivity settlement. Active work, pending input, and
1717live background work keep the thread active. T3 Code settles from a closed or merged pull request
1818only when its timestamp is not older than the user's latest activity. If that timestamp is not
1919available, the inactivity rule still applies. A manual un-settle also keeps the thread active.
20Change these rules in **Settings > General** for the environment. A settings change affects future
21settlement and does not reopen a settled thread. Settings saved by older clients on one device no
22longer control this behavior.
20
21Change these rules in **Settings > General**. The change is written to every environment you are
22connected to at that moment. An environment that is offline keeps its old value. When a connected
23environment 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
25the new-thread workspace mode and the source control writing style.
26
27A settings change affects future settlement and does not reopen a settled thread. Settings saved
28by older clients on one device no longer control this behavior.
2329
2430When you un-settle a thread, it returns to the top of the active list so you can find it right
2531away. Its timestamps do not change. Other threads keep their positions.
packages/client-runtime/package.json+4
......@@ -147,6 +147,10 @@
147147 "types": "./src/state/session.ts",
148148 "default": "./src/state/session.ts"
149149 },
150 "./state/shared-settings": {
151 "types": "./src/state/sharedSettings.ts",
152 "default": "./src/state/sharedSettings.ts"
153 },
150154 "./state/shell": {
151155 "types": "./src/state/shell.ts",
152156 "default": "./src/state/shell.ts"
packages/client-runtime/src/state/sharedSettings.test.ts created+107
......@@ -0,0 +1,107 @@
1import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 findSharedSettingsMismatches,
6 pickSharedServerSettings,
7 splitSharedServerPatch,
8} from "./sharedSettings.ts";
9
10const primaryId = EnvironmentId.make("env-primary");
11const laptopId = EnvironmentId.make("env-laptop");
12const boxId = EnvironmentId.make("env-box");
13
14describe("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
26describe("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
38describe("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});
packages/client-runtime/src/state/sharedSettings.ts created+90
......@@ -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 */
11import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts";
12import * as Equal from "effect/Equal";
13import * as Struct from "effect/Struct";
14
15/** Server keys that hold a user preference rather than machine config. */
16export 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
24export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number];
25
26const 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. */
29export 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. */
49export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch {
50 return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS);
51}
52
53export 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 */
68export 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}