From f2f99038d3a58e839c9ba7de3ce8d2b0e653ba00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 02:37:27 -0700 Subject: [PATCH 1/2] feat(mobile): add model favorites to picker --- apps/mobile/src/components/AppSymbol.tsx | 4 + .../features/threads/ThreadSettingsSheet.tsx | 261 ++++++++++++++---- .../thread-settings-sheet-state.test.ts | 43 +++ .../threads/thread-settings-sheet-state.ts | 34 +++ .../src/persistence/mobile-preferences.ts | 19 +- 5 files changed, 301 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index f3ad961ce842..b65fb769e75e 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -82,6 +82,8 @@ import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStar from "@tabler/icons-react-native/IconStar"; +import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; @@ -180,6 +182,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "square.and.pencil": IconEdit, "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, + star: IconStar, + "star.fill": IconStarFilled, "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, terminal: IconTerminal2, diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index f0f29529f4a7..e9038fcdc66b 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -7,6 +7,7 @@ import type { RuntimeMode, } from "@t3tools/contracts"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { HeaderHeightContext } from "@react-navigation/elements"; import { @@ -20,6 +21,7 @@ import { type NativeStackNavigationProp, } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; +import { AsyncResult } from "effect/unstable/reactivity"; import { createContext, use, @@ -53,6 +55,7 @@ import { } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; @@ -68,9 +71,12 @@ import { import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; import { canCommitPendingModel, + favoritesFirst, + modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, providerSectionIsCollapsed, + toggleModelFavorite, } from "./thread-settings-sheet-state"; /** @@ -101,74 +107,103 @@ const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects Platform.OS, Platform.Version, ); +const EMPTY_MODEL_FAVORITES: ReadonlyArray<{ + readonly provider: ProviderInstanceId; + readonly model: string; +}> = []; +const FAVORITES_PROVIDER_FILTER = "@favorites"; function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly onPress: () => void; + readonly isFavorite: boolean; + readonly favoritesLoaded: boolean; + readonly onToggleFavorite: () => void; readonly isFirst: boolean; readonly isLast: boolean; }) { const selectedMaterialRow = Platform.OS === "android" && props.selected; return ( - - {Platform.OS === "android" ? : null} - - - - {props.option.label} - - {props.option.isDefault ? ( - - Default - - ) : null} - {props.option.isLegacy ? ( - - Legacy - - ) : null} - {props.option.isUnavailable ? ( - Unavailable + + {Platform.OS === "android" ? : null} + + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + {props.option.isUnavailable ? ( + Unavailable + ) : null} + + {props.option.subtitle ? ( + + {props.option.subtitle} + ) : null} - {props.option.subtitle ? ( - - {props.option.subtitle} - + {props.selected && Platform.OS !== "android" ? ( + ) : null} - - {props.selected && Platform.OS !== "android" ? ( + + - ) : null} - + + ); } @@ -384,6 +419,9 @@ type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; + readonly favoriteKeys: ReadonlySet; + readonly favoritesLoaded: boolean; + readonly toggleFavorite: (option: ModelOption) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; @@ -410,6 +448,27 @@ const ThreadSettingsSessionContext = createContext + new Set( + modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)), + ), + [modelFavorites], + ); + const toggleFavorite = useCallback( + (option: ModelOption) => { + if (!favoritesLoaded) return; + void Haptics.selectionAsync(); + savePreferences({ modelFavorites: toggleModelFavorite(modelFavorites, option) }); + }, + [favoritesLoaded, modelFavorites, savePreferences], + ); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -515,6 +574,8 @@ function ThreadSettingsSessionProvider( runtimeMode: props.runtimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, + favoriteKeys, + favoritesLoaded, providerExpansionOverrides, hasLegacyModels, pendingModel, @@ -530,11 +591,14 @@ function ThreadSettingsSessionProvider( setSearchQuery, setShowLegacy: setShowLegacyToggle, toggleProvider, + toggleFavorite, }), [ applyOptionChange, commitPendingModel, displayedDescriptors, + favoriteKeys, + favoritesLoaded, providerExpansionOverrides, hasLegacyModels, isApplied, @@ -550,6 +614,7 @@ function ThreadSettingsSessionProvider( searchQuery, showLegacyToggle, toggleProvider, + toggleFavorite, ], ); @@ -616,6 +681,9 @@ function ThreadSettingsModelListRow(props: { isFirst={props.isFirst} isLast={props.isLast} onPress={onPress} + isFavorite={session.favoriteKeys.has(props.option.key)} + favoritesLoaded={session.favoritesLoaded} + onToggleFavorite={() => session.toggleFavorite(props.option)} option={props.option} selected={session.isDisplayed(props.option)} /> @@ -649,19 +717,35 @@ function useThreadSettingsCatalogItems( return useMemo( () => session.providerGroups.flatMap((group) => { - if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { + if ( + session.providerFilter !== null && + session.providerFilter !== FAVORITES_PROVIDER_FILTER && + group.providerKey !== session.providerFilter + ) { return []; } const driver = group.models[0]?.providerDriver ?? group.providerKey; - const catalogModels = session.showLegacy - ? group.models - : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); - const visibleModels = catalogModels.filter((model) => - modelMatchesCatalogQuery({ - model, - providerLabel: group.providerLabel, - query: session.searchQuery, - }), + const catalogModels = + session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER + ? group.models + : group.models.filter( + (model) => + !model.isLegacy || + session.isDisplayed(model) || + session.favoriteKeys.has(model.key), + ); + const visibleModels = favoritesFirst( + catalogModels.filter( + (model) => + (session.providerFilter !== FAVORITES_PROVIDER_FILTER || + session.favoriteKeys.has(model.key)) && + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ), + session.favoriteKeys, ); if (visibleModels.length === 0) { return []; @@ -705,6 +789,7 @@ function useThreadSettingsCatalogItems( [ session.isApplied, session.isDisplayed, + session.favoriteKeys, session.providerExpansionOverrides, session.providerFilter, session.providerGroups, @@ -834,7 +919,12 @@ function ThreadSettingsMainContent(props: { content = ( - {hasActiveCatalogFilter ? "No matching models" : "No available models"} + {session.providerFilter === FAVORITES_PROVIDER_FILTER && + session.searchQuery.trim().length === 0 + ? "No favorite models" + : hasActiveCatalogFilter + ? "No matching models" + : "No available models"} ); @@ -857,7 +947,13 @@ function ThreadSettingsMainContent(props: { ); }, - [animationsReady, hasActiveCatalogFilter, props.onOpenSubmenu], + [ + animationsReady, + hasActiveCatalogFilter, + props.onOpenSubmenu, + session.providerFilter, + session.searchQuery, + ], ); return ( @@ -918,6 +1014,38 @@ function ThreadSettingsMainContent(props: { /> ) : null} + + session.setProviderFilter(null)} + > + All + + session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} + > + Favorites + + ) : null} @@ -1077,6 +1205,15 @@ function ThreadSettingsModelsScreen() { state: session.providerFilter === null ? ("on" as const) : ("off" as const), onPress: () => session.setProviderFilter(null), }, + { + type: "action" as const, + title: "Favorites", + state: + session.providerFilter === FAVORITES_PROVIDER_FILTER + ? ("on" as const) + : ("off" as const), + onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER), + }, ...session.providerGroups.map((group) => ({ type: "action" as const, title: group.providerLabel, @@ -1213,6 +1350,12 @@ function ThreadSettingsModelsScreen() { > All providers + session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} + > + Favorites + {session.providerGroups.map((group) => ( { + it("keeps favorites in catalog order ahead of other models", () => { + const models = [ + modelOption("first"), + modelOption("second"), + modelOption("third"), + modelOption("fourth"), + ]; + const favorites = new Set([models[2]!.key, models[0]!.key]); + + expect(favoritesFirst(models, favorites).map((model) => model.selection.model)).toEqual([ + "first", + "third", + "second", + "fourth", + ]); + expect(models.map((model) => model.selection.model)).toEqual([ + "first", + "second", + "third", + "fourth", + ]); + }); + + it("adds and removes favorites for one provider instance", () => { + const codexModel = modelOption("shared"); + const otherProvider = ProviderInstanceId.make("codex_personal"); + const personalModel = { + ...codexModel, + key: modelFavoriteKey(otherProvider, "shared"), + selection: { ...codexModel.selection, instanceId: otherProvider }, + }; + const favorites = toggleModelFavorite([], codexModel); + + expect(toggleModelFavorite(favorites, personalModel)).toEqual([ + { provider: ProviderInstanceId.make("codex"), model: "shared" }, + { provider: otherProvider, model: "shared" }, + ]); + expect(toggleModelFavorite(favorites, codexModel)).toEqual([]); + }); + it("matches visible model and provider terms", () => { const model = modelOption("gpt-next"); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index 28d318d491e9..e600eb3980b8 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,4 +1,38 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import type { ProviderInstanceId } from "@t3tools/contracts"; + +export type ModelFavorite = { + readonly provider: ProviderInstanceId; + readonly model: string; +}; + +export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { + return `${provider}:${model}`; +} + +export function toggleModelFavorite( + favorites: ReadonlyArray, + option: ModelOption, +): ReadonlyArray { + const provider = option.selection.instanceId; + const model = option.selection.model; + return favorites.some((favorite) => favorite.provider === provider && favorite.model === model) + ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model) + : [...favorites, { provider, model }]; +} + +/** Keep catalog order within each group when favorites move to the front. */ +export function favoritesFirst( + models: ReadonlyArray, + favoriteKeys: ReadonlySet, +): ReadonlyArray { + const favorites: ModelOption[] = []; + const others: ModelOption[] = []; + for (const model of models) { + (favoriteKeys.has(model.key) ? favorites : others).push(model); + } + return [...favorites, ...others]; +} /** Match the terms a user can actually see or recognize in the model picker. */ export function modelMatchesCatalogQuery(input: { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 2959a18d8c63..508630b1e581 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; @@ -43,6 +43,11 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Model favorites belong to this device, like the web client setting. */ + readonly modelFavorites?: ReadonlyArray<{ + readonly provider: ProviderInstanceId; + readonly model: string; + }>; /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; @@ -104,6 +109,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; } = {}; @@ -176,6 +182,17 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (Array.isArray(parsed.modelFavorites)) { + preferences.modelFavorites = parsed.modelFavorites.filter( + (favorite) => + typeof favorite === "object" && + favorite !== null && + typeof favorite.provider === "string" && + favorite.provider.length > 0 && + typeof favorite.model === "string" && + favorite.model.trim().length > 0, + ); + } if (typeof parsed.threadListSettledShelfExpanded === "boolean") { preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } From f8ea35bc90fdbed4c20db6bbf0e2ce3b05aa77e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 02:43:00 -0700 Subject: [PATCH 2/2] fix(mobile): preserve rapid favorite updates --- .../features/threads/ThreadSettingsSheet.tsx | 10 ++++- apps/mobile/src/state/preferences.test.ts | 40 +++++++++++++++++++ apps/mobile/src/state/preferences.ts | 13 +++++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index e9038fcdc66b..6c0218d453f5 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -48,6 +48,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -465,9 +466,14 @@ function ThreadSettingsSessionProvider( (option: ModelOption) => { if (!favoritesLoaded) return; void Haptics.selectionAsync(); - savePreferences({ modelFavorites: toggleModelFavorite(modelFavorites, option) }); + savePreferences((current: Preferences) => ({ + modelFavorites: toggleModelFavorite( + current.modelFavorites ?? EMPTY_MODEL_FAVORITES, + option, + ), + })); }, - [favoritesLoaded, modelFavorites, savePreferences], + [favoritesLoaded, savePreferences], ); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState(null); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb2306..48b7badcc2c2 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { vi } from "vite-plus/test"; +import { ProviderInstanceId } from "@t3tools/contracts"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -124,6 +125,45 @@ describe("mobile preferences state", () => { }), ); + it.effect("keeps both favorites when updates are sent before a render", () => + Effect.gen(function* () { + let persisted: Preferences = { modelFavorites: [] }; + const state = makePreferencesState({ + load: Effect.succeed(persisted), + savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")), + update: (transform) => + Effect.sync(() => { + persisted = { ...persisted, ...transform(persisted) }; + return persisted; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }); + + const provider = ProviderInstanceId.make("codex"); + registry.set(state.updatePreferencesAtom, (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], + })); + registry.set(state.updatePreferencesAtom, (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], + })); + yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { + suspendOnWaiting: true, + }); + + expect(persisted.modelFavorites).toEqual([ + { provider, model: "astra" }, + { provider, model: "sol" }, + ]); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("falls back to empty preferences when secure storage cannot be read", () => Effect.gen(function* () { const state = makePreferencesState({ diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5a..bcecc69db458 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,6 +15,8 @@ interface OptimisticPreferences { readonly versions: Partial>; } +type PreferencesUpdate = Partial | ((current: Preferences) => Partial); + /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is @@ -57,7 +59,12 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime, get) => { + (update: PreferencesUpdate, get) => { + const currentPreferences = get(preferencesAtom); + const patch = + typeof update === "function" + ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}) + : update; const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; @@ -69,7 +76,9 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime store.savePatch(patch)), + Effect.flatMap((store) => + typeof update === "function" ? store.update(update) : store.savePatch(patch), + ), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved);