Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 39 additions & 24 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -556,41 +559,62 @@ 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<string | null>(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,
primarySettings: referenceSettings,
environments: environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
connected: environment.connection.phase === "connected",
connected: supportsSharedSettings(environment),
settings: environment.serverConfig?.settings ?? null,
})),
});
Expand Down Expand Up @@ -623,15 +647,14 @@ function AutoSettleSettingsRows() {
<SettingsSwitchRow
icon="clock"
label="Auto-settle inactive threads"
subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`}
value={afterDays !== null}
onValueChange={(value) =>
writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })
}
/>
{afterDays !== null ? (
<View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
<Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text>
<Text className="flex-1 text-lg text-foreground">Days without activity</Text>
<TextInput
className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base"
keyboardType="number-pad"
Expand All @@ -640,7 +663,7 @@ function AutoSettleSettingsRows() {
onChangeText={setDaysDraft}
onBlur={commitDays}
onSubmitEditing={commitDays}
accessibilityLabel="Days before auto-settle"
accessibilityLabel="Days without activity before auto-settle"
/>
</View>
) : null}
Expand All @@ -654,15 +677,7 @@ function AutoSettleSettingsRows() {
</View>
<Pressable
accessibilityRole="button"
onPress={() => {
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"
>
<Text className="text-base font-t3-medium text-foreground">Apply to all</Text>
Expand Down
121 changes: 69 additions & 52 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";

Expand Down Expand Up @@ -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<EnvironmentId> {
function useConnectedEnvironments(): ReadonlyArray<SettingsWriteTarget> {
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<SettingsWriteTarget>, 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.
*
Expand All @@ -366,11 +397,12 @@ function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> {
* 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);
Expand All @@ -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) {
Expand All @@ -417,7 +443,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
});
}
},
[connectedEnvironmentIds, environmentId, persistServerSettings],
[connectedEnvironments, environmentId, targetLabel, writeServerSettings],
);

return updateSettings;
Expand All @@ -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(
() =>
Expand All @@ -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 };
}
Expand Down
5 changes: 3 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading