diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 58a2779840c3..2e480a1f19a3 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -39,13 +39,16 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; import { DEFAULT_SERVER_SETTINGS, + type EnvironmentId, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, type ServerSettingsPatch, } from "@t3tools/contracts"; import { + describeRejectedSettingsWrites, findSharedSettingsMismatches, pickSharedServerSettings, + supportsSharedSettings, } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -556,33 +559,54 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD * has no primary environment, so the first connected environment that * supports it is the reference value. Edits fan out to every connected * environment, and a mismatch row lets the user push the reference out. + * + * Writes are awaited so a rejected environment (a dropped session, a server + * that refuses the scope) is reported instead of leaving the control looking + * saved. The server echoes every accepted write through the config + * subscription, so no optimistic state is kept here. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "server settings update", - reportFailure: true, + reportFailure: false, }); - const connected = environments.filter( - (environment) => - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, - ); + const connected = environments.filter(supportsSharedSettings); const reference = connected[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); + const writeTo = useCallback( + async ( + targets: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>, + patch: ServerSettingsPatch, + ) => { + const failed: Array<{ readonly label: string; readonly error: unknown }> = []; + await Promise.all( + targets.map(async (target) => { + const result = await updateSettings({ + environmentId: target.environmentId, + input: { patch }, + }); + if (AsyncResult.isFailure(result) && !isAtomCommandInterrupted(result)) { + failed.push({ label: target.label, error: squashAtomCommandFailure(result) }); + } + }), + ); + if (failed.length > 0) { + Alert.alert("Setting not saved", describeRejectedSettingsWrites(failed)); + } + }, + [updateSettings], + ); + if (reference === null || referenceSettings === null) { return null; } - const writeToAll = (patch: ServerSettingsPatch) => { - for (const environment of connected) { - void updateSettings({ environmentId: environment.environmentId, input: { patch } }); - } - }; + const writeToAll = (patch: ServerSettingsPatch) => void writeTo(connected, patch); const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: reference.environmentId, @@ -590,7 +614,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: environment.connection.phase === "connected", + connected: supportsSharedSettings(environment), settings: environment.serverConfig?.settings ?? null, })), }); @@ -623,7 +647,6 @@ function AutoSettleSettingsRows() { writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) @@ -631,7 +654,7 @@ function AutoSettleSettingsRows() { /> {afterDays !== null ? ( - Days before auto-settle + Days without activity ) : null} @@ -654,15 +677,7 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings(referenceSettings); - for (const mismatch of mismatches) { - void updateSettings({ - environmentId: mismatch.environmentId, - input: { patch }, - }); - } - }} + onPress={() => void writeTo(mismatches, pickSharedServerSettings(referenceSettings))} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > Apply to all diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6eb571f70aac..032883041b6a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,9 +26,15 @@ import { } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + describeRejectedSettingsWrites, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettings, } from "@t3tools/client-runtime/state/shared-settings"; import { ensureLocalApi } from "~/localApi"; import { @@ -42,11 +48,7 @@ import * as Struct from "effect/Struct"; import { toastManager } from "~/components/ui/toast"; import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironment, -} from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -332,30 +334,59 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } -/** - * Whether an environment can hold every shared key right now. Gated on the - * auto-settlement capability because it is the newest of the shared keys: a - * server that has it has all of them. Older servers drop unknown keys on - * write, so a mismatch against them could never clear, and their decoded - * defaults must not be treated as real values. - */ -function supportsSharedSettings(environment: EnvironmentPresentation): boolean { - return ( - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true - ); -} - /** Environments that can receive a shared settings write right now. */ -function useConnectedEnvironmentIds(): ReadonlyArray { +function useConnectedEnvironments(): ReadonlyArray { const { environments } = useEnvironments(); return useMemo( () => - environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), + environments + .filter(supportsSharedSettings) + .map(({ environmentId, label }) => ({ environmentId, label })), [environments], ); } +interface SettingsWriteTarget { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +/** + * A silent failure leaves the control looking saved, so every rejected write + * (a dropped session, a server that refuses the scope) is named in a toast. + * Accepted writes come back through the config subscription and need nothing. + */ +function useWriteServerSettings() { + const persistServerSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "server settings update", + reportFailure: false, + }); + return useCallback( + async (targets: ReadonlyArray, patch: ServerSettingsPatch) => { + const failed: Array<{ readonly label: string; readonly error: unknown }> = []; + await Promise.all( + targets.map(async (target) => { + const result = await persistServerSettings({ + environmentId: target.environmentId, + input: { patch }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failed.push({ label: target.label, error: squashAtomCommandFailure(result) }); + } + }), + ); + if (failed.length > 0) { + toastManager.add({ + type: "error", + title: "Setting not saved", + description: describeRejectedSettingsWrites(failed), + }); + } + }, + [persistServerSettings], + ); +} + /** * Returns an updater that routes each key to the correct backing store. * @@ -366,11 +397,12 @@ function useConnectedEnvironmentIds(): ReadonlyArray { * client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { - const persistServerSettings = useAtomCommand( - serverEnvironment.updateSettings, - "server settings update", - ); - const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const writeServerSettings = useWriteServerSettings(); + const connectedEnvironments = useConnectedEnvironments(); + const { environments } = useEnvironments(); + const targetLabel = + environments.find((environment) => environment.environmentId === environmentId)?.label ?? + "This environment"; const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -386,28 +418,22 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); if (Object.keys(localPatch).length > 0) { if (environmentId) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, - }); + void writeServerSettings([{ environmentId, label: targetLabel }], localPatch); } else { warnUnsaved(); } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set(connectedEnvironmentIds); - if (environmentId) { - targets.add(environmentId); + const targets = new Map( + connectedEnvironments.map((target) => [target.environmentId, target]), + ); + if (environmentId && !targets.has(environmentId)) { + targets.set(environmentId, { environmentId, label: targetLabel }); } if (targets.size === 0) { warnUnsaved(); } - for (const targetId of targets) { - void persistServerSettings({ - environmentId: targetId, - input: { patch: sharedPatch }, - }); - } + void writeServerSettings([...targets.values()], sharedPatch); } } if (Object.keys(clientPatch).length > 0) { @@ -417,7 +443,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } }, - [connectedEnvironmentIds, environmentId, persistServerSettings], + [connectedEnvironments, environmentId, targetLabel, writeServerSettings], ); return updateSettings; @@ -441,10 +467,7 @@ export function useSharedSettingsSync() { ? (primaryEnvironment.serverConfig?.settings ?? null) : null; const { environments } = useEnvironments(); - const persistServerSettings = useAtomCommand( - serverEnvironment.updateSettings, - "server settings update", - ); + const writeServerSettings = useWriteServerSettings(); const mismatches = useMemo( () => @@ -465,14 +488,8 @@ export function useSharedSettingsSync() { if (primarySettings === null) { return; } - const patch = pickSharedServerSettings(primarySettings); - for (const mismatch of mismatches) { - void persistServerSettings({ - environmentId: mismatch.environmentId, - input: { patch }, - }); - } - }, [mismatches, persistServerSettings, primarySettings]); + void writeServerSettings(mismatches, pickSharedServerSettings(primarySettings)); + }, [mismatches, primarySettings, writeServerSettings]); return { mismatches, applyToAll }; } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 01b64fbae74b..151a01aa18c6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -25,8 +25,9 @@ turn, not by when the server noticed it was inactive. Change these rules in **Settings > General**. The change is written to every environment you are connected to at that moment. An environment that is offline keeps its old value. When a connected environment holds a different value, **Settings > General** shows a warning that names it. Choose -**Apply to all** to write your current values to every connected environment. The same applies to -the new-thread workspace mode and the source control writing style. +**Apply to all** to write your current values to every connected environment. If an environment +rejects the write, the app tells you which one and why. The same applies to the new-thread workspace +mode and the source control writing style. A settings change affects future settlement and does not reopen a settled thread. Settings saved by older clients on one device no longer control this behavior. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index dbdf651180d3..01fe982615ae 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -1,10 +1,19 @@ -import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { BearerConnectionProfile, type ConnectionCatalogEntry } from "../connection/catalog.ts"; +import { BearerConnectionTarget } from "../connection/model.ts"; +import type { + EnvironmentConnectionPhase, + EnvironmentPresentation, +} from "../connection/presentation.ts"; import { + describeRejectedSettingsWrites, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettings, } from "./sharedSettings.ts"; const primaryId = EnvironmentId.make("env-primary"); @@ -38,6 +47,40 @@ describe("pickSharedServerSettings", () => { describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + it("compares nested shared keys by value so an applied write clears the mismatch", () => { + const environments = [ + { + environmentId: boxId, + label: "Remote Box", + connected: true, + settings: { + ...primarySettings, + sourceControlWritingStyle: { ...primarySettings.sourceControlWritingStyle }, + }, + }, + ]; + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments, + }), + ).toEqual([]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: { + ...primarySettings, + sourceControlWritingStyle: { + ...primarySettings.sourceControlWritingStyle, + customInstructions: "Keep it short.", + }, + }, + environments, + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }); + it("lists connected environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, @@ -105,3 +148,55 @@ describe("findSharedSettingsMismatches", () => { expect(mismatches).toEqual([]); }); }); + +describe("supportsSharedSettings", () => { + const target = new BearerConnectionTarget({ + environmentId: boxId, + label: "Remote Box", + connectionId: "connection-1", + }); + const entry: ConnectionCatalogEntry = { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), + }; + const presentation = ( + phase: EnvironmentConnectionPhase, + threadAutoSettlement: boolean | null, + ): EnvironmentPresentation => ({ + entry, + connection: { phase, error: null, traceId: null }, + serverConfig: + threadAutoSettlement === null + ? null + : ({ + environment: { capabilities: { repositoryIdentity: true, threadAutoSettlement } }, + settings: DEFAULT_SERVER_SETTINGS, + } as ServerConfig), + }); + + it("requires a live connection to a server that holds every shared key", () => { + expect(supportsSharedSettings(presentation("connected", true))).toBe(true); + expect(supportsSharedSettings(presentation("connecting", true))).toBe(false); + expect(supportsSharedSettings(presentation("connected", false))).toBe(false); + expect(supportsSharedSettings(presentation("connected", null))).toBe(false); + }); +}); + +describe("describeRejectedSettingsWrites", () => { + it("keeps each environment's own reason", () => { + expect( + describeRejectedSettingsWrites([ + { label: "Laptop", error: new Error("Laptop is not connected.") }, + { label: "Remote Box", error: { _tag: "EnvironmentAuthorizationError" } }, + ]), + ).toBe("Laptop: Laptop is not connected.\nRemote Box: The server rejected the change."); + }); +}); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 35fa4adb46bf..82b8fe39744d 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -12,6 +12,8 @@ import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tool import * as Equal from "effect/Equal"; import * as Struct from "effect/Struct"; +import type { EnvironmentPresentation } from "../connection/presentation.ts"; + /** Server keys that hold a user preference rather than machine config. */ export const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", @@ -50,6 +52,36 @@ export function pickSharedServerSettings(settings: ServerSettings): ServerSettin return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); } +/** + * Whether an environment can hold every shared key right now. Gated on the + * auto-settlement capability because it is the newest of the shared keys: a + * server that has it has all of them. Older servers drop unknown keys on + * write, so a mismatch against them could never clear, and their decoded + * defaults must not be treated as real values. + */ +export function supportsSharedSettings(environment: EnvironmentPresentation): boolean { + return ( + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true + ); +} + +/** + * One line per environment that rejected a settings write, each with its own + * reason. Two environments can fail for different reasons (one dropped its + * session, one refused the scope), so the reasons are never collapsed. + */ +export function describeRejectedSettingsWrites( + failed: ReadonlyArray<{ readonly label: string; readonly error: unknown }>, +): string { + return failed + .map( + ({ label, error }) => + `${label}: ${error instanceof Error ? error.message : "The server rejected the change."}`, + ) + .join("\n"); +} + export interface SharedSettingsEnvironment { readonly environmentId: EnvironmentId; readonly label: string;