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 {...@@ -19,7 +19,7 @@ import {
19 squashAtomCommandFailure,19 squashAtomCommandFailure,
20} from "@t3tools/client-runtime/state/runtime";20} from "@t3tools/client-runtime/state/runtime";
21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22import { AppText as Text } from "../../components/AppText";22import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
23import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";23import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
24import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";24import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
25import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";25import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
...@@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime";...@@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime";
36import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";36import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
37import { serverEnvironment } from "../../state/server";37import { serverEnvironment } from "../../state/server";
38import { useAtomCommand } from "../../state/use-atom-command";38import { 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";
40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";50import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
41import {51import {
42 type AppUpdateCheckState,52 type AppUpdateCheckState,
...@@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() {...@@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() {
530}540}
531541
532function GeneralSettingsSection() {542function GeneralSettingsSection() {
533 const { savedConnectionsById } = useSavedRemoteConnections();
534 const connections = Object.values(savedConnectionsById).sort((left, right) =>
535 left.environmentLabel.localeCompare(right.environmentLabel),
536 );
537
538 return (543 return (
539 <SettingsSection title="General">544 <SettingsSection title="General">
540 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />545 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
541 {connections.map((connection) => (546 <AutoSettleSettingsRows />
542 <EnvironmentAutoSettleSwitch
543 key={connection.environmentId}
544 environmentId={connection.environmentId}
545 environmentLabel={connection.environmentLabel}
546 />
547 ))}
548 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />547 <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
549 </SettingsSection>548 </SettingsSection>
550 );549 );
551}550}
552551
553function EnvironmentAutoSettleSwitch(props: {552const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
554 readonly environmentId: EnvironmentId;553
555 readonly environmentLabel: string;554/**
556}) {555 * Auto-settlement is a user preference that every server has to hold. Mobile
557 const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId));556 * has no primary environment, so the first connected environment that
558 const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));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();
559 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {562 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
560 label: "auto-settle settings update",563 label: "server settings update",
561 reportFailure: true,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 return null;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 return (615 return (
567 <SettingsSwitchRow616 <>
568 icon="arrow.triangle.branch"617 <SettingsSwitchRow
569 label={`Auto-settle merged threads · ${props.environmentLabel}`}618 icon="arrow.triangle.branch"
570 value={settings?.sidebarAutoSettleOnMerge ?? true}619 label="Auto-settle merged threads"
571 onValueChange={(value) => {620 value={referenceSettings.sidebarAutoSettleOnMerge}
572 void updateSettings({621 onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })}
573 environmentId: props.environmentId,622 />
574 input: { patch: { sidebarAutoSettleOnMerge: value } },623 <SettingsSwitchRow
575 });624 icon="clock"
576 }}625 label="Auto-settle inactive threads"
577 />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}
580675
apps/web/src/components/settings/SettingsPanels.tsx+2
...@@ -112,6 +112,7 @@ import {...@@ -112,6 +112,7 @@ import {
112 TYPOGRAPHY_ADVANCED_STORAGE_KEY,112 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
113} from "../../appearanceFonts";113} from "../../appearanceFonts";
114import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";114import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
115import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
115import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";116import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
116import {117import {
117 NumberField,118 NumberField,
...@@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() {...@@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() {
19281929
1929 return (1930 return (
1930 <SettingsPageContainer>1931 <SettingsPageContainer>
1932 <SharedSettingsMismatchAlert />
1931 <SettingsSection title="General">1933 <SettingsSection title="General">
1932 <SettingsRow1934 <SettingsRow
1933 {...searchableSetting("project-grouping")}1935 {...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 {...@@ -18,6 +18,7 @@ import {
18} from "@t3tools/shared/backgroundActivitySettings";18} from "@t3tools/shared/backgroundActivitySettings";
1919
20import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";20import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
21import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
21import { cn } from "../../lib/utils";22import { cn } from "../../lib/utils";
22import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";23import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
23import { useEnvironmentQuery } from "../../state/query";24import { useEnvironmentQuery } from "../../state/query";
...@@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() {...@@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() {
548549
549 return (550 return (
550 <SettingsPageContainer>551 <SettingsPageContainer>
552 <SharedSettingsMismatchAlert />
551 {isInitialScanPending ? (553 {isInitialScanPending ? (
552 <>554 <>
553 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />555 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />
apps/web/src/hooks/useSettings.ts+109-5
...@@ -25,6 +25,11 @@ import {...@@ -25,6 +25,11 @@ import {
25 type UnifiedSettings,25 type UnifiedSettings,
26} from "@t3tools/contracts/settings";26} from "@t3tools/contracts/settings";
27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import {
29 findSharedSettingsMismatches,
30 pickSharedServerSettings,
31 splitSharedServerPatch,
32} from "@t3tools/client-runtime/state/shared-settings";
28import { ensureLocalApi } from "~/localApi";33import { ensureLocalApi } from "~/localApi";
29import {34import {
30 getThemeDefinition,35 getThemeDefinition,
...@@ -35,7 +40,11 @@ import {...@@ -35,7 +40,11 @@ import {
35} from "~/themePalette";40} from "~/themePalette";
36import * as Struct from "effect/Struct";41import * as Struct from "effect/Struct";
37import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";42import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
38import { usePrimaryEnvironment } from "~/state/environments";43import {
44 type EnvironmentPresentation,
45 useEnvironments,
46 usePrimaryEnvironment,
47} from "~/state/environments";
39import { useAtomCommand } from "~/state/use-atom-command";48import { useAtomCommand } from "~/state/use-atom-command";
40import { useTheme } from "./useTheme";49import { useTheme } from "./useTheme";
4150
...@@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>(...@@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>(
307 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);316 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);
308}317}
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
310/**343/**
311 * Returns an updater that routes each key to the correct backing store.344 * Returns an updater that routes each key to the correct backing store.
312 *345 *
313 * Server keys are optimistically patched in atom-backed server state, then346 * 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 */
316function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {352function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
317 const persistServerSettings = useAtomCommand(353 const persistServerSettings = useAtomCommand(
318 serverEnvironment.updateSettings,354 serverEnvironment.updateSettings,
319 "server settings update",355 "server settings update",
320 );356 );
357 const connectedEnvironmentIds = useConnectedEnvironmentIds();
321 const updateSettings = useCallback(358 const updateSettings = useCallback(
322 (patch: UnifiedSettingsPatch) => {359 (patch: UnifiedSettingsPatch) => {
323 const { serverPatch, clientPatch } = splitPatch(patch);360 const { serverPatch, clientPatch } = splitPatch(patch);
324361
325 if (Object.keys(serverPatch).length > 0) {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 void persistServerSettings({365 void persistServerSettings({
328 environmentId,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 if (Object.keys(clientPatch).length > 0) {383 if (Object.keys(clientPatch).length > 0) {
334 persistClientSettings({384 persistClientSettings({
...@@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
337 });387 });
338 }388 }
339 },389 },
340 [environmentId, persistServerSettings],390 [connectedEnvironmentIds, environmentId, persistServerSettings],
341 );391 );
342392
343 return updateSettings;393 return updateSettings;
344}394}
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
346export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {450export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
347 return useUpdateSettingsTarget(environmentId);451 return useUpdateSettingsTarget(environmentId);
348}452}
docs/internals/overview.md+4-1
...@@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by...@@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by
88`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later88`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
89does not define turn end.89does 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.
92[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and95[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
93once per minute, including when no client is connected. It dispatches the guarded internal96once per minute, including when no client is connected. It dispatches the guarded internal
94`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic97`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...@@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p
10Pinned threads still move to **Settled** when they become inactive. They also move when their pull10Pinned threads still move to **Settled** when they become inactive. They also move when their pull
11request merges if **Auto-settle merged threads** is enabled.11request merges if **Auto-settle merged threads** is enabled.
1212
13Each environment owns its automatic settlement settings. The server checks them even when no web,13Each server stores its own copy of the automatic settlement settings and checks them even when no
14desktop, or mobile client is connected. By default, it settles threads after three days without14web, desktop, or mobile client is connected. By default, it settles threads after three days without
15activity and when their pull request merges. An eligible idle thread also settles when its pull15activity and when their pull request merges. An eligible idle thread also settles when its pull
16request closes. An open pull request blocks inactivity settlement. Active work, pending input, and16request closes. An open pull request blocks inactivity settlement. Active work, pending input, and
17live background work keep the thread active. T3 Code settles from a closed or merged pull request17live background work keep the thread active. T3 Code settles from a closed or merged pull request
18only when its timestamp is not older than the user's latest activity. If that timestamp is not18only when its timestamp is not older than the user's latest activity. If that timestamp is not
19available, the inactivity rule still applies. A manual un-settle also keeps the thread active.19available, 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 future20
21settlement and does not reopen a settled thread. Settings saved by older clients on one device no21Change these rules in **Settings > General**. The change is written to every environment you are
22longer control this behavior.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
24When you un-settle a thread, it returns to the top of the active list so you can find it right30When you un-settle a thread, it returns to the top of the active list so you can find it right
25away. Its timestamps do not change. Other threads keep their positions.31away. Its timestamps do not change. Other threads keep their positions.
packages/client-runtime/package.json+4
...@@ -147,6 +147,10 @@...@@ -147,6 +147,10 @@
147 "types": "./src/state/session.ts",147 "types": "./src/state/session.ts",
148 "default": "./src/state/session.ts"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 "./state/shell": {154 "./state/shell": {
151 "types": "./src/state/shell.ts",155 "types": "./src/state/shell.ts",
152 "default": "./src/state/shell.ts"156 "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}