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";...@@ -47,7 +47,6 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
47import { applyProviderOptionSelection } from "../../lib/providerOptions";47import { applyProviderOptionSelection } from "../../lib/providerOptions";
48import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";48import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
49import { useUniwindTheme } from "../../lib/useUniwindTheme";49import { useUniwindTheme } from "../../lib/useUniwindTheme";
50import type { Preferences } from "../../persistence/mobile-preferences";
51import {50import {
52 NativeHeaderToolbar,51 NativeHeaderToolbar,
53 NativeStackScreenOptions,52 NativeStackScreenOptions,
...@@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider(...@@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider(
331 (option: ModelOption) => {330 (option: ModelOption) => {
332 if (!favoritesLoaded) return;331 if (!favoritesLoaded) return;
333 void Haptics.selectionAsync();332 void Haptics.selectionAsync();
334 savePreferences((current: Preferences) => ({333 savePreferences({
335 modelFavorites: toggleModelFavorite(334 transform: (current) => ({
336 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,335 modelFavorites: toggleModelFavorite(
337 option,336 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
338 ),337 option,
339 }));338 ),
339 }),
340 });
340 },341 },
341 [favoritesLoaded, savePreferences],342 [favoritesLoaded, savePreferences],
342 );343 );
apps/mobile/src/state/preferences.test.ts+45-8
...@@ -5,6 +5,9 @@ import * as Option from "effect/Option";...@@ -5,6 +5,9 @@ import * as Option from "effect/Option";
5import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";5import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
6import { vi } from "vite-plus/test";6import { vi } from "vite-plus/test";
7import { ProviderInstanceId } from "@t3tools/contracts";7import { ProviderInstanceId } from "@t3tools/contracts";
8import { RegistryContext, useAtomSet } from "@effect/atom-react";
9import { createElement } from "react";
10import { renderToString } from "react-dom/server";
811
9vi.mock("expo-secure-store", () => ({12vi.mock("expo-secure-store", () => ({
10 getItemAsync: vi.fn(),13 getItemAsync: vi.fn(),
...@@ -125,12 +128,16 @@ describe("mobile preferences state", () => {...@@ -125,12 +128,16 @@ describe("mobile preferences state", () => {
125 }),128 }),
126 );129 );
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", () =>
129 Effect.gen(function* () {132 Effect.gen(function* () {
130 let persisted: Preferences = { modelFavorites: [] };133 let persisted: Preferences = { modelFavorites: [] };
131 const state = makePreferencesState({134 const state = makePreferencesState({
132 load: Effect.succeed(persisted),135 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 }),
134 update: (transform) =>141 update: (transform) =>
135 Effect.sync(() => {142 Effect.sync(() => {
136 persisted = { ...persisted, ...transform(persisted) };143 persisted = { ...persisted, ...transform(persisted) };
...@@ -142,13 +149,31 @@ describe("mobile preferences state", () => {...@@ -142,13 +149,31 @@ describe("mobile preferences state", () => {
142 const unmountUpdate = registry.mount(state.updatePreferencesAtom);149 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
143 yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });150 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]!;
145 const provider = ProviderInstanceId.make("codex");166 const provider = ProviderInstanceId.make("codex");
146 registry.set(state.updatePreferencesAtom, (current) => ({167 savePreferences({
147 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],168 transform: (current) => ({
148 }));169 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
149 registry.set(state.updatePreferencesAtom, (current) => ({170 }),
150 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],171 });
151 }));172 savePreferences({
173 transform: (current) => ({
174 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
175 }),
176 });
152 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {177 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
153 suspendOnWaiting: true,178 suspendOnWaiting: true,
154 });179 });
...@@ -158,6 +183,18 @@ describe("mobile preferences state", () => {...@@ -158,6 +183,18 @@ describe("mobile preferences state", () => {
158 { provider, model: "sol" },183 { provider, model: "sol" },
159 ]);184 ]);
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
161 unmountUpdate();198 unmountUpdate();
162 unmountPreferences();199 unmountPreferences();
163 registry.dispose();200 registry.dispose();
apps/mobile/src/state/preferences.ts+10-4
...@@ -15,7 +15,11 @@ interface OptimisticPreferences {...@@ -15,7 +15,11 @@ interface OptimisticPreferences {
15 readonly versions: Partial<Record<keyof Preferences, number>>;15 readonly versions: Partial<Record<keyof Preferences, number>>;
16}16}
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
20/**24/**
21 * Owns the device preference blob for the lifetime of the app registry.25 * Owns the device preference blob for the lifetime of the app registry.
...@@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre...@@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
62 (update: PreferencesUpdate, get) => {66 (update: PreferencesUpdate, get) => {
63 const currentPreferences = get(preferencesAtom);67 const currentPreferences = get(preferencesAtom);
64 const patch =68 const patch =
65 typeof update === "function"69 "transform" in update
66 ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {})70 ? update.transform(
71 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
72 )
67 : update;73 : update;
68 const version = ++nextPatchVersion;74 const version = ++nextPatchVersion;
69 const current = get(optimisticPatchAtom);75 const current = get(optimisticPatchAtom);
...@@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre...@@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
77 });83 });
78 return MobilePreferencesStore.pipe(84 return MobilePreferencesStore.pipe(
79 Effect.flatMap((store) =>85 Effect.flatMap((store) =>
80 typeof update === "function" ? store.update(update) : store.savePatch(patch),86 "transform" in update ? store.update(update.transform) : store.savePatch(patch),
81 ),87 ),
82 Effect.tap((saved) =>88 Effect.tap((saved) =>
83 Effect.sync(() => {89 Effect.sync(() => {