authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 14:23:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 14:23:09-07:00
log82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08
tree72e785e6103ae2902c07222563b3cc56dc4bda63
parent93e04160a0c0dece8a258384d2118a660415a53d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve multiple model favorites (#12505)


3 files changed, 63 insertions(+), 19 deletions(-)

apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+8-7
......@@ -47,7 +47,6 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
4747import { applyProviderOptionSelection } from "../../lib/providerOptions";
4848import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
4949import { useUniwindTheme } from "../../lib/useUniwindTheme";
50import type { Preferences } from "../../persistence/mobile-preferences";
5150import {
5251 NativeHeaderToolbar,
5352 NativeStackScreenOptions,
......@@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider(
331330 (option: ModelOption) => {
332331 if (!favoritesLoaded) return;
333332 void Haptics.selectionAsync();
334 savePreferences((current: Preferences) => ({
335 modelFavorites: toggleModelFavorite(
336 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
337 option,
338 ),
339 }));
333 savePreferences({
334 transform: (current) => ({
335 modelFavorites: toggleModelFavorite(
336 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
337 option,
338 ),
339 }),
340 });
340341 },
341342 [favoritesLoaded, savePreferences],
342343 );
apps/mobile/src/state/preferences.test.ts+45-8
......@@ -5,6 +5,9 @@ import * as Option from "effect/Option";
55import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
66import { vi } from "vite-plus/test";
77import { ProviderInstanceId } from "@t3tools/contracts";
8import { RegistryContext, useAtomSet } from "@effect/atom-react";
9import { createElement } from "react";
10import { renderToString } from "react-dom/server";
811
912vi.mock("expo-secure-store", () => ({
1013 getItemAsync: vi.fn(),
......@@ -125,12 +128,16 @@ describe("mobile preferences state", () => {
125128 }),
126129 );
127130
128 it.effect("keeps both favorites when updates are sent before a render", () =>
131 it.effect("keeps both favorites when the React setter sends updates before a render", () =>
129132 Effect.gen(function* () {
130133 let persisted: Preferences = { modelFavorites: [] };
131134 const state = makePreferencesState({
132135 load: Effect.succeed(persisted),
133 savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")),
136 savePatch: (patch) =>
137 Effect.sync(() => {
138 persisted = { ...persisted, ...patch };
139 return persisted;
140 }),
134141 update: (transform) =>
135142 Effect.sync(() => {
136143 persisted = { ...persisted, ...transform(persisted) };
......@@ -142,13 +149,31 @@ describe("mobile preferences state", () => {
142149 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
143150 yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
144151
152 function useSavePreferences() {
153 return useAtomSet(state.updatePreferencesAtom);
154 }
155 const setters: Array<ReturnType<typeof useSavePreferences>> = [];
156 function CaptureSetter() {
157 setters.push(useSavePreferences());
158 return null;
159 }
160 // Exercise the real React setter, which treats bare functions as updates
161 // to the atom's read value. Direct registry.set calls bypass that behavior.
162 renderToString(
163 createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)),
164 );
165 const savePreferences = setters[0]!;
145166 const provider = ProviderInstanceId.make("codex");
146 registry.set(state.updatePreferencesAtom, (current) => ({
147 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
148 }));
149 registry.set(state.updatePreferencesAtom, (current) => ({
150 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
151 }));
167 savePreferences({
168 transform: (current) => ({
169 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
170 }),
171 });
172 savePreferences({
173 transform: (current) => ({
174 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
175 }),
176 });
152177 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
153178 suspendOnWaiting: true,
154179 });
......@@ -158,6 +183,18 @@ describe("mobile preferences state", () => {
158183 { provider, model: "sol" },
159184 ]);
160185
186 savePreferences({
187 transform: (current) => ({
188 modelFavorites: (current.modelFavorites ?? []).filter(
189 (favorite) => favorite.model !== "astra",
190 ),
191 }),
192 });
193 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
194 suspendOnWaiting: true,
195 });
196 expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]);
197
161198 unmountUpdate();
162199 unmountPreferences();
163200 registry.dispose();
apps/mobile/src/state/preferences.ts+10-4
......@@ -15,7 +15,11 @@ interface OptimisticPreferences {
1515 readonly versions: Partial<Record<keyof Preferences, number>>;
1616}
1717
18type PreferencesUpdate = Partial<Preferences> | ((current: Preferences) => Partial<Preferences>);
18// A bare function is interpreted by useAtomSet as an update to the command's
19// AsyncResult, not as a preference transform. Keep transforms inside a payload.
20type PreferencesUpdate =
21 | Partial<Preferences>
22 | { readonly transform: (current: Preferences) => Partial<Preferences> };
1923
2024/**
2125 * Owns the device preference blob for the lifetime of the app registry.
......@@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
6266 (update: PreferencesUpdate, get) => {
6367 const currentPreferences = get(preferencesAtom);
6468 const patch =
65 typeof update === "function"
66 ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {})
69 "transform" in update
70 ? update.transform(
71 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
72 )
6773 : update;
6874 const version = ++nextPatchVersion;
6975 const current = get(optimisticPatchAtom);
......@@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
7783 });
7884 return MobilePreferencesStore.pipe(
7985 Effect.flatMap((store) =>
80 typeof update === "function" ? store.update(update) : store.savePatch(patch),
86 "transform" in update ? store.update(update.transform) : store.savePatch(patch),
8187 ),
8288 Effect.tap((saved) =>
8389 Effect.sync(() => {