authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 03:15:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 03:15:34-07:00
log298f8c95cd7c20ea7943a431f22e836fa637457b
treec10ecdb4bdd0719f797b4c32772eea047479996e
parentdd9528a974ba534faa8d1588f3f137fa2a281860
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add favorites to model picker (#12231)


7 files changed, 358 insertions(+), 62 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+4
...@@ -82,6 +82,8 @@ import IconServer from "@tabler/icons-react-native/IconServer";...@@ -82,6 +82,8 @@ import IconServer from "@tabler/icons-react-native/IconServer";
82import IconSettings from "@tabler/icons-react-native/IconSettings";82import IconSettings from "@tabler/icons-react-native/IconSettings";
83import IconSparkles from "@tabler/icons-react-native/IconSparkles";83import IconSparkles from "@tabler/icons-react-native/IconSparkles";
84import IconStack2 from "@tabler/icons-react-native/IconStack2";84import IconStack2 from "@tabler/icons-react-native/IconStack2";
85import IconStar from "@tabler/icons-react-native/IconStar";
86import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
85import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";87import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
86import IconSun from "@tabler/icons-react-native/IconSun";88import IconSun from "@tabler/icons-react-native/IconSun";
87import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";89import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
...@@ -180,6 +182,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -180,6 +182,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
180 "square.and.pencil": IconEdit,182 "square.and.pencil": IconEdit,
181 "square.grid.2x2": IconApps,183 "square.grid.2x2": IconApps,
182 "square.split.2x1": IconLayoutColumns,184 "square.split.2x1": IconLayoutColumns,
185 star: IconStar,
186 "star.fill": IconStarFilled,
183 "sun.max": IconSun,187 "sun.max": IconSun,
184 "stop.fill": IconPlayerStopFilled,188 "stop.fill": IconPlayerStopFilled,
185 terminal: IconTerminal2,189 terminal: IconTerminal2,
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+208-59
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7 RuntimeMode,7 RuntimeMode,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { LegendListRenderItemProps } from "@legendapp/list/react-native";9import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
10import { useAtomSet, useAtomValue } from "@effect/atom-react";
10import { AnimatedLegendList } from "@legendapp/list/reanimated";11import { AnimatedLegendList } from "@legendapp/list/reanimated";
11import { HeaderHeightContext } from "@react-navigation/elements";12import { HeaderHeightContext } from "@react-navigation/elements";
12import {13import {
...@@ -20,6 +21,7 @@ import {...@@ -20,6 +21,7 @@ import {
20 type NativeStackNavigationProp,21 type NativeStackNavigationProp,
21} from "@react-navigation/native-stack";22} from "@react-navigation/native-stack";
22import * as Haptics from "expo-haptics";23import * as Haptics from "expo-haptics";
24import { AsyncResult } from "effect/unstable/reactivity";
23import {25import {
24 createContext,26 createContext,
25 use,27 use,
...@@ -46,6 +48,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";...@@ -46,6 +48,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
46import { applyProviderOptionSelection } from "../../lib/providerOptions";48import { applyProviderOptionSelection } from "../../lib/providerOptions";
47import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";49import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
48import { useUniwindTheme } from "../../lib/useUniwindTheme";50import { useUniwindTheme } from "../../lib/useUniwindTheme";
51import type { Preferences } from "../../persistence/mobile-preferences";
49import {52import {
50 NativeHeaderToolbar,53 NativeHeaderToolbar,
51 NativeStackScreenOptions,54 NativeStackScreenOptions,
...@@ -53,6 +56,7 @@ import {...@@ -53,6 +56,7 @@ import {
53} from "../../native/StackHeader";56} from "../../native/StackHeader";
54import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";57import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
55import { serverEnvironment } from "../../state/server";58import { serverEnvironment } from "../../state/server";
59import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
56import { useAtomCommand } from "../../state/use-atom-command";60import { useAtomCommand } from "../../state/use-atom-command";
57import { useNewTaskFlow } from "./new-task-flow-provider";61import { useNewTaskFlow } from "./new-task-flow-provider";
58import { MaterialScreenContent } from "../../components/MaterialScreenContent";62import { MaterialScreenContent } from "../../components/MaterialScreenContent";
...@@ -68,9 +72,12 @@ import {...@@ -68,9 +72,12 @@ import {
68import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";72import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
69import {73import {
70 canCommitPendingModel,74 canCommitPendingModel,
75 favoritesFirst,
76 modelFavoriteKey,
71 modelMatchesCatalogQuery,77 modelMatchesCatalogQuery,
72 pendingModelAfterPress,78 pendingModelAfterPress,
73 providerSectionIsCollapsed,79 providerSectionIsCollapsed,
80 toggleModelFavorite,
74} from "./thread-settings-sheet-state";81} from "./thread-settings-sheet-state";
7582
76/**83/**
...@@ -101,74 +108,103 @@ const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects...@@ -101,74 +108,103 @@ const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects
101 Platform.OS,108 Platform.OS,
102 Platform.Version,109 Platform.Version,
103);110);
111const EMPTY_MODEL_FAVORITES: ReadonlyArray<{
112 readonly provider: ProviderInstanceId;
113 readonly model: string;
114}> = [];
115const FAVORITES_PROVIDER_FILTER = "@favorites";
104function ModelRow(props: {116function ModelRow(props: {
105 readonly option: ModelOption;117 readonly option: ModelOption;
106 readonly selected: boolean;118 readonly selected: boolean;
107 readonly onPress: () => void;119 readonly onPress: () => void;
120 readonly isFavorite: boolean;
121 readonly favoritesLoaded: boolean;
122 readonly onToggleFavorite: () => void;
108 readonly isFirst: boolean;123 readonly isFirst: boolean;
109 readonly isLast: boolean;124 readonly isLast: boolean;
110}) {125}) {
111 const selectedMaterialRow = Platform.OS === "android" && props.selected;126 const selectedMaterialRow = Platform.OS === "android" && props.selected;
112 return (127 return (
113 <Pressable128 <View
114 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}129 style={Platform.OS === "android" ? { minHeight: 56 } : undefined}
115 accessibilityRole="radio"
116 accessibilityState={{
117 checked: props.selected,
118 disabled: props.option.isUnavailable === true,
119 }}
120 disabled={props.option.isUnavailable}
121 onPress={props.onPress}
122 style={Platform.OS === "android" ? { minHeight: 56, paddingVertical: 12 } : undefined}
123 className={cn(130 className={cn(
124 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",131 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4",
125 selectedMaterialRow && "bg-thread-selected",132 selectedMaterialRow && "bg-thread-selected",
126 props.isFirst && "rounded-t-2xl",133 props.isFirst && "rounded-t-2xl",
127 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",134 props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle",
128 )}135 )}
129 >136 >
130 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}137 <Pressable
131 <View className="min-w-0 flex-1">138 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
132 <View className="flex-row items-center gap-2">139 accessibilityRole="radio"
133 <Text140 accessibilityState={{
134 className="min-w-0 shrink text-base font-t3-medium text-foreground"141 checked: props.selected,
135 numberOfLines={Platform.OS === "android" ? 2 : 1}142 disabled: props.option.isUnavailable === true,
136 >143 }}
137 {props.option.label}144 className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"
138 </Text>145 disabled={props.option.isUnavailable}
139 {props.option.isDefault ? (146 onPress={props.onPress}
140 <View className="rounded-md bg-subtle-strong px-1.5 py-0.5">147 >
141 <Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text>148 {Platform.OS === "android" ? <MaterialRadioIndicator selected={props.selected} /> : null}
142 </View>149 <View className="min-w-0 flex-1">
143 ) : null}150 <View className="flex-row items-center gap-2">
144 {props.option.isLegacy ? (151 <Text
145 <View className="rounded-md bg-subtle px-1.5 py-0.5">152 className="min-w-0 shrink text-base font-t3-medium text-foreground"
146 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>153 numberOfLines={Platform.OS === "android" ? 2 : 1}
147 </View>154 >
148 ) : null}155 {props.option.label}
149 {props.option.isUnavailable ? (156 </Text>
150 <Text className="text-xs text-foreground">Unavailable</Text>157 {props.option.isDefault ? (
158 <View className="rounded-md bg-subtle-strong px-1.5 py-0.5">
159 <Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text>
160 </View>
161 ) : null}
162 {props.option.isLegacy ? (
163 <View className="rounded-md bg-subtle px-1.5 py-0.5">
164 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
165 </View>
166 ) : null}
167 {props.option.isUnavailable ? (
168 <Text className="text-xs text-foreground">Unavailable</Text>
169 ) : null}
170 </View>
171 {props.option.subtitle ? (
172 <Text
173 className="text-xs text-foreground-muted"
174 numberOfLines={Platform.OS === "android" ? 2 : 1}
175 >
176 {props.option.subtitle}
177 </Text>
151 ) : null}178 ) : null}
152 </View>179 </View>
153 {props.option.subtitle ? (180 {props.selected && Platform.OS !== "android" ? (
154 <Text181 <SymbolView
155 className="text-xs text-foreground-muted"182 name="checkmark"
156 numberOfLines={Platform.OS === "android" ? 2 : 1}183 size={16}
157 >184 tintColorClassName="accent-icon"
158 {props.option.subtitle}185 type="monochrome"
159 </Text>186 weight="semibold"
187 />
160 ) : null}188 ) : null}
161 </View>189 </Pressable>
162 {props.selected && Platform.OS !== "android" ? (190 <Pressable
191 accessibilityLabel={`${props.isFavorite ? "Remove from" : "Add to"} favorites: ${
192 props.option.providerLabel
193 }, ${props.option.label}`}
194 accessibilityRole="button"
195 accessibilityState={{ disabled: !props.favoritesLoaded, selected: props.isFavorite }}
196 className="min-h-11 min-w-11 items-center justify-center"
197 disabled={!props.favoritesLoaded}
198 onPress={props.onToggleFavorite}
199 >
163 <SymbolView200 <SymbolView
164 name="checkmark"201 name={props.isFavorite ? "star.fill" : "star"}
165 size={16}202 size={18}
166 tintColorClassName="accent-icon"203 tintColorClassName={props.isFavorite ? "accent-icon" : "accent-icon-subtle"}
167 type="monochrome"204 type="monochrome"
168 weight="semibold"
169 />205 />
170 ) : null}206 </Pressable>
171 </Pressable>207 </View>
172 );208 );
173}209}
174210
...@@ -384,6 +420,9 @@ type ThreadSettingsSessionValue = {...@@ -384,6 +420,9 @@ type ThreadSettingsSessionValue = {
384 readonly environmentId: EnvironmentId | null;420 readonly environmentId: EnvironmentId | null;
385 readonly providerInstanceId?: ProviderInstanceId;421 readonly providerInstanceId?: ProviderInstanceId;
386 readonly providerGroups: ReadonlyArray<ProviderGroup>;422 readonly providerGroups: ReadonlyArray<ProviderGroup>;
423 readonly favoriteKeys: ReadonlySet<string>;
424 readonly favoritesLoaded: boolean;
425 readonly toggleFavorite: (option: ModelOption) => void;
387 readonly runtimeMode: RuntimeMode;426 readonly runtimeMode: RuntimeMode;
388 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;427 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
389 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;428 readonly displayedDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
...@@ -410,6 +449,32 @@ const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue |...@@ -410,6 +449,32 @@ const ThreadSettingsSessionContext = createContext<ThreadSettingsSessionValue |
410function ThreadSettingsSessionProvider(449function ThreadSettingsSessionProvider(
411 props: ThreadSettingsSessionProps & { readonly children: ReactNode },450 props: ThreadSettingsSessionProps & { readonly children: ReactNode },
412) {451) {
452 const preferences = useAtomValue(mobilePreferencesAtom);
453 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
454 const favoritesLoaded = AsyncResult.isSuccess(preferences);
455 const modelFavorites = favoritesLoaded
456 ? (preferences.value.modelFavorites ?? EMPTY_MODEL_FAVORITES)
457 : EMPTY_MODEL_FAVORITES;
458 const favoriteKeys = useMemo(
459 () =>
460 new Set(
461 modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)),
462 ),
463 [modelFavorites],
464 );
465 const toggleFavorite = useCallback(
466 (option: ModelOption) => {
467 if (!favoritesLoaded) return;
468 void Haptics.selectionAsync();
469 savePreferences((current: Preferences) => ({
470 modelFavorites: toggleModelFavorite(
471 current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
472 option,
473 ),
474 }));
475 },
476 [favoritesLoaded, savePreferences],
477 );
413 const [showLegacyToggle, setShowLegacyToggle] = useState(false);478 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
414 const [providerFilter, setProviderFilter] = useState<string | null>(null);479 const [providerFilter, setProviderFilter] = useState<string | null>(null);
415 const [searchQuery, setSearchQuery] = useState("");480 const [searchQuery, setSearchQuery] = useState("");
...@@ -515,6 +580,8 @@ function ThreadSettingsSessionProvider(...@@ -515,6 +580,8 @@ function ThreadSettingsSessionProvider(
515 runtimeMode: props.runtimeMode,580 runtimeMode: props.runtimeMode,
516 onUpdateRuntimeMode: props.onUpdateRuntimeMode,581 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
517 displayedDescriptors,582 displayedDescriptors,
583 favoriteKeys,
584 favoritesLoaded,
518 providerExpansionOverrides,585 providerExpansionOverrides,
519 hasLegacyModels,586 hasLegacyModels,
520 pendingModel,587 pendingModel,
...@@ -530,11 +597,14 @@ function ThreadSettingsSessionProvider(...@@ -530,11 +597,14 @@ function ThreadSettingsSessionProvider(
530 setSearchQuery,597 setSearchQuery,
531 setShowLegacy: setShowLegacyToggle,598 setShowLegacy: setShowLegacyToggle,
532 toggleProvider,599 toggleProvider,
600 toggleFavorite,
533 }),601 }),
534 [602 [
535 applyOptionChange,603 applyOptionChange,
536 commitPendingModel,604 commitPendingModel,
537 displayedDescriptors,605 displayedDescriptors,
606 favoriteKeys,
607 favoritesLoaded,
538 providerExpansionOverrides,608 providerExpansionOverrides,
539 hasLegacyModels,609 hasLegacyModels,
540 isApplied,610 isApplied,
...@@ -550,6 +620,7 @@ function ThreadSettingsSessionProvider(...@@ -550,6 +620,7 @@ function ThreadSettingsSessionProvider(
550 searchQuery,620 searchQuery,
551 showLegacyToggle,621 showLegacyToggle,
552 toggleProvider,622 toggleProvider,
623 toggleFavorite,
553 ],624 ],
554 );625 );
555626
...@@ -616,6 +687,9 @@ function ThreadSettingsModelListRow(props: {...@@ -616,6 +687,9 @@ function ThreadSettingsModelListRow(props: {
616 isFirst={props.isFirst}687 isFirst={props.isFirst}
617 isLast={props.isLast}688 isLast={props.isLast}
618 onPress={onPress}689 onPress={onPress}
690 isFavorite={session.favoriteKeys.has(props.option.key)}
691 favoritesLoaded={session.favoritesLoaded}
692 onToggleFavorite={() => session.toggleFavorite(props.option)}
619 option={props.option}693 option={props.option}
620 selected={session.isDisplayed(props.option)}694 selected={session.isDisplayed(props.option)}
621 />695 />
...@@ -649,19 +723,35 @@ function useThreadSettingsCatalogItems(...@@ -649,19 +723,35 @@ function useThreadSettingsCatalogItems(
649 return useMemo(723 return useMemo(
650 () =>724 () =>
651 session.providerGroups.flatMap((group) => {725 session.providerGroups.flatMap((group) => {
652 if (session.providerFilter !== null && group.providerKey !== session.providerFilter) {726 if (
727 session.providerFilter !== null &&
728 session.providerFilter !== FAVORITES_PROVIDER_FILTER &&
729 group.providerKey !== session.providerFilter
730 ) {
653 return [];731 return [];
654 }732 }
655 const driver = group.models[0]?.providerDriver ?? group.providerKey;733 const driver = group.models[0]?.providerDriver ?? group.providerKey;
656 const catalogModels = session.showLegacy734 const catalogModels =
657 ? group.models735 session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER
658 : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model));736 ? group.models
659 const visibleModels = catalogModels.filter((model) =>737 : group.models.filter(
660 modelMatchesCatalogQuery({738 (model) =>
661 model,739 !model.isLegacy ||
662 providerLabel: group.providerLabel,740 session.isDisplayed(model) ||
663 query: session.searchQuery,741 session.favoriteKeys.has(model.key),
664 }),742 );
743 const visibleModels = favoritesFirst(
744 catalogModels.filter(
745 (model) =>
746 (session.providerFilter !== FAVORITES_PROVIDER_FILTER ||
747 session.favoriteKeys.has(model.key)) &&
748 modelMatchesCatalogQuery({
749 model,
750 providerLabel: group.providerLabel,
751 query: session.searchQuery,
752 }),
753 ),
754 session.favoriteKeys,
665 );755 );
666 if (visibleModels.length === 0) {756 if (visibleModels.length === 0) {
667 return [];757 return [];
...@@ -705,6 +795,7 @@ function useThreadSettingsCatalogItems(...@@ -705,6 +795,7 @@ function useThreadSettingsCatalogItems(
705 [795 [
706 session.isApplied,796 session.isApplied,
707 session.isDisplayed,797 session.isDisplayed,
798 session.favoriteKeys,
708 session.providerExpansionOverrides,799 session.providerExpansionOverrides,
709 session.providerFilter,800 session.providerFilter,
710 session.providerGroups,801 session.providerGroups,
...@@ -834,7 +925,12 @@ function ThreadSettingsMainContent(props: {...@@ -834,7 +925,12 @@ function ThreadSettingsMainContent(props: {
834 content = (925 content = (
835 <View className="items-center px-8 py-14">926 <View className="items-center px-8 py-14">
836 <Text className="text-center text-sm text-foreground-muted">927 <Text className="text-center text-sm text-foreground-muted">
837 {hasActiveCatalogFilter ? "No matching models" : "No available models"}928 {session.providerFilter === FAVORITES_PROVIDER_FILTER &&
929 session.searchQuery.trim().length === 0
930 ? "No favorite models"
931 : hasActiveCatalogFilter
932 ? "No matching models"
933 : "No available models"}
838 </Text>934 </Text>
839 </View>935 </View>
840 );936 );
...@@ -857,7 +953,13 @@ function ThreadSettingsMainContent(props: {...@@ -857,7 +953,13 @@ function ThreadSettingsMainContent(props: {
857 </Animated.View>953 </Animated.View>
858 );954 );
859 },955 },
860 [animationsReady, hasActiveCatalogFilter, props.onOpenSubmenu],956 [
957 animationsReady,
958 hasActiveCatalogFilter,
959 props.onOpenSubmenu,
960 session.providerFilter,
961 session.searchQuery,
962 ],
861 );963 );
862964
863 return (965 return (
...@@ -918,6 +1020,38 @@ function ThreadSettingsMainContent(props: {...@@ -918,6 +1020,38 @@ function ThreadSettingsMainContent(props: {
918 />1020 />
919 ) : null}1021 ) : null}
920 </View>1022 </View>
1023 <View className="flex-row gap-2 pt-3">
1024 <Pressable
1025 accessibilityRole="button"
1026 accessibilityState={{
1027 selected: session.providerFilter !== FAVORITES_PROVIDER_FILTER,
1028 }}
1029 className={cn(
1030 "rounded-full px-4 py-2",
1031 session.providerFilter !== FAVORITES_PROVIDER_FILTER
1032 ? "bg-subtle-strong"
1033 : "bg-subtle",
1034 )}
1035 onPress={() => session.setProviderFilter(null)}
1036 >
1037 <Text className="text-sm font-t3-medium text-foreground">All</Text>
1038 </Pressable>
1039 <Pressable
1040 accessibilityRole="button"
1041 accessibilityState={{
1042 selected: session.providerFilter === FAVORITES_PROVIDER_FILTER,
1043 }}
1044 className={cn(
1045 "rounded-full px-4 py-2",
1046 session.providerFilter === FAVORITES_PROVIDER_FILTER
1047 ? "bg-subtle-strong"
1048 : "bg-subtle",
1049 )}
1050 onPress={() => session.setProviderFilter(FAVORITES_PROVIDER_FILTER)}
1051 >
1052 <Text className="text-sm font-t3-medium text-foreground">Favorites</Text>
1053 </Pressable>
1054 </View>
921 </View>1055 </View>
922 ) : null}1056 ) : null}
923 </>1057 </>
...@@ -1077,6 +1211,15 @@ function ThreadSettingsModelsScreen() {...@@ -1077,6 +1211,15 @@ function ThreadSettingsModelsScreen() {
1077 state: session.providerFilter === null ? ("on" as const) : ("off" as const),1211 state: session.providerFilter === null ? ("on" as const) : ("off" as const),
1078 onPress: () => session.setProviderFilter(null),1212 onPress: () => session.setProviderFilter(null),
1079 },1213 },
1214 {
1215 type: "action" as const,
1216 title: "Favorites",
1217 state:
1218 session.providerFilter === FAVORITES_PROVIDER_FILTER
1219 ? ("on" as const)
1220 : ("off" as const),
1221 onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER),
1222 },
1080 ...session.providerGroups.map((group) => ({1223 ...session.providerGroups.map((group) => ({
1081 type: "action" as const,1224 type: "action" as const,
1082 title: group.providerLabel,1225 title: group.providerLabel,
...@@ -1213,6 +1356,12 @@ function ThreadSettingsModelsScreen() {...@@ -1213,6 +1356,12 @@ function ThreadSettingsModelsScreen() {
1213 >1356 >
1214 All providers1357 All providers
1215 </NativeHeaderToolbar.MenuAction>1358 </NativeHeaderToolbar.MenuAction>
1359 <NativeHeaderToolbar.MenuAction
1360 isOn={session.providerFilter === FAVORITES_PROVIDER_FILTER}
1361 onPress={() => session.setProviderFilter(FAVORITES_PROVIDER_FILTER)}
1362 >
1363 Favorites
1364 </NativeHeaderToolbar.MenuAction>
1216 {session.providerGroups.map((group) => (1365 {session.providerGroups.map((group) => (
1217 <NativeHeaderToolbar.MenuAction1366 <NativeHeaderToolbar.MenuAction
1218 key={group.providerKey}1367 key={group.providerKey}
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+43
...@@ -5,8 +5,11 @@ import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contr...@@ -5,8 +5,11 @@ import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contr
5import type { ModelOption } from "../../lib/modelOptions";5import type { ModelOption } from "../../lib/modelOptions";
6import {6import {
7 canCommitPendingModel,7 canCommitPendingModel,
8 favoritesFirst,
9 modelFavoriteKey,
8 modelMatchesCatalogQuery,10 modelMatchesCatalogQuery,
9 pendingModelAfterPress,11 pendingModelAfterPress,
12 toggleModelFavorite,
10} from "./thread-settings-sheet-state";13} from "./thread-settings-sheet-state";
1114
12function modelOption(15function modelOption(
...@@ -32,6 +35,46 @@ function modelOption(...@@ -32,6 +35,46 @@ function modelOption(
32}35}
3336
34describe("thread settings sheet state", () => {37describe("thread settings sheet state", () => {
38 it("keeps favorites in catalog order ahead of other models", () => {
39 const models = [
40 modelOption("first"),
41 modelOption("second"),
42 modelOption("third"),
43 modelOption("fourth"),
44 ];
45 const favorites = new Set([models[2]!.key, models[0]!.key]);
46
47 expect(favoritesFirst(models, favorites).map((model) => model.selection.model)).toEqual([
48 "first",
49 "third",
50 "second",
51 "fourth",
52 ]);
53 expect(models.map((model) => model.selection.model)).toEqual([
54 "first",
55 "second",
56 "third",
57 "fourth",
58 ]);
59 });
60
61 it("adds and removes favorites for one provider instance", () => {
62 const codexModel = modelOption("shared");
63 const otherProvider = ProviderInstanceId.make("codex_personal");
64 const personalModel = {
65 ...codexModel,
66 key: modelFavoriteKey(otherProvider, "shared"),
67 selection: { ...codexModel.selection, instanceId: otherProvider },
68 };
69 const favorites = toggleModelFavorite([], codexModel);
70
71 expect(toggleModelFavorite(favorites, personalModel)).toEqual([
72 { provider: ProviderInstanceId.make("codex"), model: "shared" },
73 { provider: otherProvider, model: "shared" },
74 ]);
75 expect(toggleModelFavorite(favorites, codexModel)).toEqual([]);
76 });
77
35 it("matches visible model and provider terms", () => {78 it("matches visible model and provider terms", () => {
36 const model = modelOption("gpt-next");79 const model = modelOption("gpt-next");
3780
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+34
...@@ -1,4 +1,38 @@...@@ -1,4 +1,38 @@
1import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";1import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
2import type { ProviderInstanceId } from "@t3tools/contracts";
3
4export type ModelFavorite = {
5 readonly provider: ProviderInstanceId;
6 readonly model: string;
7};
8
9export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string {
10 return `${provider}:${model}`;
11}
12
13export function toggleModelFavorite(
14 favorites: ReadonlyArray<ModelFavorite>,
15 option: ModelOption,
16): ReadonlyArray<ModelFavorite> {
17 const provider = option.selection.instanceId;
18 const model = option.selection.model;
19 return favorites.some((favorite) => favorite.provider === provider && favorite.model === model)
20 ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model)
21 : [...favorites, { provider, model }];
22}
23
24/** Keep catalog order within each group when favorites move to the front. */
25export function favoritesFirst(
26 models: ReadonlyArray<ModelOption>,
27 favoriteKeys: ReadonlySet<string>,
28): ReadonlyArray<ModelOption> {
29 const favorites: ModelOption[] = [];
30 const others: ModelOption[] = [];
31 for (const model of models) {
32 (favoriteKeys.has(model.key) ? favorites : others).push(model);
33 }
34 return [...favorites, ...others];
35}
236
3/** Match the terms a user can actually see or recognize in the model picker. */37/** Match the terms a user can actually see or recognize in the model picker. */
4export function modelMatchesCatalogQuery(input: {38export function modelMatchesCatalogQuery(input: {
apps/mobile/src/persistence/mobile-preferences.ts+18-1
...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";8import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts";
9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";9import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
10import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";10import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
11import * as MobileDatabase from "./mobile-database";11import * as MobileDatabase from "./mobile-database";
...@@ -43,6 +43,11 @@ export interface Preferences {...@@ -43,6 +43,11 @@ export interface Preferences {
43 readonly legacyThreadListEnabled?: boolean;43 readonly legacyThreadListEnabled?: boolean;
44 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */44 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
45 readonly planModeEnabled?: boolean;45 readonly planModeEnabled?: boolean;
46 /** Model favorites belong to this device, like the web client setting. */
47 readonly modelFavorites?: ReadonlyArray<{
48 readonly provider: ProviderInstanceId;
49 readonly model: string;
50 }>;
46 /** Fresh keys reset both shelves to collapsed when users update. */51 /** Fresh keys reset both shelves to collapsed when users update. */
47 readonly threadListSettledShelfExpanded?: boolean;52 readonly threadListSettledShelfExpanded?: boolean;
48 readonly threadListSnoozedShelfExpanded?: boolean;53 readonly threadListSnoozedShelfExpanded?: boolean;
...@@ -104,6 +109,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -104,6 +109,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
104 projectGroupingMode?: SidebarProjectGroupingMode;109 projectGroupingMode?: SidebarProjectGroupingMode;
105 legacyThreadListEnabled?: boolean;110 legacyThreadListEnabled?: boolean;
106 planModeEnabled?: boolean;111 planModeEnabled?: boolean;
112 modelFavorites?: Preferences["modelFavorites"];
107 threadListSettledShelfExpanded?: boolean;113 threadListSettledShelfExpanded?: boolean;
108 threadListSnoozedShelfExpanded?: boolean;114 threadListSnoozedShelfExpanded?: boolean;
109 } = {};115 } = {};
...@@ -176,6 +182,17 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -176,6 +182,17 @@ function sanitizePreferences(parsed: Preferences): Preferences {
176 if (typeof parsed.planModeEnabled === "boolean") {182 if (typeof parsed.planModeEnabled === "boolean") {
177 preferences.planModeEnabled = parsed.planModeEnabled;183 preferences.planModeEnabled = parsed.planModeEnabled;
178 }184 }
185 if (Array.isArray(parsed.modelFavorites)) {
186 preferences.modelFavorites = parsed.modelFavorites.filter(
187 (favorite) =>
188 typeof favorite === "object" &&
189 favorite !== null &&
190 typeof favorite.provider === "string" &&
191 favorite.provider.length > 0 &&
192 typeof favorite.model === "string" &&
193 favorite.model.trim().length > 0,
194 );
195 }
179 if (typeof parsed.threadListSettledShelfExpanded === "boolean") {196 if (typeof parsed.threadListSettledShelfExpanded === "boolean") {
180 preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded;197 preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded;
181 }198 }
apps/mobile/src/state/preferences.test.ts+40
...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";4import * 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";
78
8vi.mock("expo-secure-store", () => ({9vi.mock("expo-secure-store", () => ({
9 getItemAsync: vi.fn(),10 getItemAsync: vi.fn(),
...@@ -124,6 +125,45 @@ describe("mobile preferences state", () => {...@@ -124,6 +125,45 @@ describe("mobile preferences state", () => {
124 }),125 }),
125 );126 );
126127
128 it.effect("keeps both favorites when updates are sent before a render", () =>
129 Effect.gen(function* () {
130 let persisted: Preferences = { modelFavorites: [] };
131 const state = makePreferencesState({
132 load: Effect.succeed(persisted),
133 savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")),
134 update: (transform) =>
135 Effect.sync(() => {
136 persisted = { ...persisted, ...transform(persisted) };
137 return persisted;
138 }),
139 });
140 const registry = AtomRegistry.make();
141 const unmountPreferences = registry.mount(state.preferencesAtom);
142 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
143 yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
144
145 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 }));
152 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
153 suspendOnWaiting: true,
154 });
155
156 expect(persisted.modelFavorites).toEqual([
157 { provider, model: "astra" },
158 { provider, model: "sol" },
159 ]);
160
161 unmountUpdate();
162 unmountPreferences();
163 registry.dispose();
164 }),
165 );
166
127 it.effect("falls back to empty preferences when secure storage cannot be read", () =>167 it.effect("falls back to empty preferences when secure storage cannot be read", () =>
128 Effect.gen(function* () {168 Effect.gen(function* () {
129 const state = makePreferencesState({169 const state = makePreferencesState({
apps/mobile/src/state/preferences.ts+11-2
...@@ -15,6 +15,8 @@ interface OptimisticPreferences {...@@ -15,6 +15,8 @@ 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>);
19
18/**20/**
19 * Owns the device preference blob for the lifetime of the app registry.21 * Owns the device preference blob for the lifetime of the app registry.
20 * Optimistic patches are kept separately so writes made while persistence is22 * Optimistic patches are kept separately so writes made while persistence is
...@@ -57,7 +59,12 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre...@@ -57,7 +59,12 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
5759
58 const updatePreferencesAtom = runtime60 const updatePreferencesAtom = runtime
59 .fn(61 .fn(
60 (patch: Partial<Preferences>, get) => {62 (update: PreferencesUpdate, get) => {
63 const currentPreferences = get(preferencesAtom);
64 const patch =
65 typeof update === "function"
66 ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {})
67 : update;
61 const version = ++nextPatchVersion;68 const version = ++nextPatchVersion;
62 const current = get(optimisticPatchAtom);69 const current = get(optimisticPatchAtom);
63 const versions = { ...current.versions };70 const versions = { ...current.versions };
...@@ -69,7 +76,9 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre...@@ -69,7 +76,9 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePre
69 versions,76 versions,
70 });77 });
71 return MobilePreferencesStore.pipe(78 return MobilePreferencesStore.pipe(
72 Effect.flatMap((store) => store.savePatch(patch)),79 Effect.flatMap((store) =>
80 typeof update === "function" ? store.update(update) : store.savePatch(patch),
81 ),
73 Effect.tap((saved) =>82 Effect.tap((saved) =>
74 Effect.sync(() => {83 Effect.sync(() => {
75 get.set(confirmedPreferencesAtom, saved);84 get.set(confirmedPreferencesAtom, saved);