History

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

Theo Browne committed 2 months ago

Commit 8efd4e91 parent 5014e5f10 files · +489 −44Browse files

Unified · Split

10 files
  1. apps/mobile/src/features/settings/SettingsRouteScreen.tsx +128 −33
  2. apps/web/src/components/settings/SettingsPanels.tsx +2 −0
  3. apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx +32 −0
  4. apps/web/src/components/settings/SourceControlSettings.tsx +2 −0
  5. apps/web/src/hooks/useSettings.ts +109 −5
  6. docs/internals/overview.md +4 −1
  7. docs/user/thread-sidebar.md +11 −5
  8. packages/client-runtime/package.json +4 −0
  9. packages/client-runtime/src/state/sharedSettings.test.ts +107 −0
  10. packages/client-runtime/src/state/sharedSettings.ts +90 −0
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+128−33
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
1919 squashAtomCommandFailure,
2020 } from "@t3tools/client-runtime/state/runtime";
2121 import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22−import { AppText as Text } from "../../components/AppText";
22+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
2323 import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
2424 import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
2525 import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
@@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; · 10 unmodified lines
3636 import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3737 import { serverEnvironment } from "../../state/server";
3838 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";
4050 import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4151 import {
4252 type AppUpdateCheckState,
@@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { · 487 unmodified lines
530540 }
531541
532542 function 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
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();
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−0
@@ -112,6 +112,7 @@ import { · 111 unmodified lines
112112 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
113113 } from "../../appearanceFonts";
114114 import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
115+import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
115116 import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
116117 import {
117118 NumberField,
@@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { · 1810 unmodified lines
19281929
19291930 return (
19301931 <SettingsPageContainer>
1932+ <SharedSettingsMismatchAlert />
19311933 <SettingsSection title="General">
19321934 <SettingsRow
19331935 {...searchableSetting("project-grouping")}
apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx+32−0
@@ -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+}
apps/web/src/components/settings/SourceControlSettings.tsx+2−0
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 } from "@t3tools/shared/backgroundActivitySettings";
1919
2020 import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
21+import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
2122 import { cn } from "../../lib/utils";
2223 import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
2324 import { useEnvironmentQuery } from "../../state/query";
@@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { · 524 unmodified lines
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 { · 24 unmodified lines
2525 type UnifiedSettings,
2626 } from "@t3tools/contracts/settings";
2727 import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28+import {
29+ findSharedSettingsMismatches,
30+ pickSharedServerSettings,
31+ splitSharedServerPatch,
32+} from "@t3tools/client-runtime/state/shared-settings";
2833 import { ensureLocalApi } from "~/localApi";
2934 import {
3035 getThemeDefinition,
@@ -35,7 +40,11 @@ import { · 4 unmodified lines
3540 } from "~/themePalette";
3641 import * as Struct from "effect/Struct";
3742 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";
3948 import { useAtomCommand } from "~/state/use-atom-command";
4049 import { useTheme } from "./useTheme";
4150
@@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>( · 265 unmodified lines
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+ */
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+
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 */
316352 function 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) { · 2 unmodified lines
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+ */
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+
346450 export 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 · 87 unmodified lines
8888 `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
8989 does not define turn end.
9090
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.
9295 [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
9396 once 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 · 9 unmodified lines
1010 Pinned threads still move to **Settled** when they become inactive. They also move when their pull
1111 request merges if **Auto-settle merged threads** is enabled.
1212
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
1515 activity and when their pull request merges. An eligible idle thread also settles when its pull
1616 request closes. An open pull request blocks inactivity settlement. Active work, pending input, and
1717 live background work keep the thread active. T3 Code settles from a closed or merged pull request
1818 only when its timestamp is not older than the user's latest activity. If that timestamp is not
1919 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.
2329
2430 When you un-settle a thread, it returns to the top of the active list so you can find it right
2531 away. Its timestamps do not change. Other threads keep their positions.
packages/client-runtime/package.json+4−0
@@ -147,6 +147,10 @@ · 146 unmodified lines
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+107−0
@@ -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+});
packages/client-runtime/src/state/sharedSettings.ts+90−0
@@ -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+}