authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 16:09:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 16:09:00-07:00
logea15f17afd94e7ce7133e52f5b5983910662c9ec
tree03e4ecff0e222eee1fd24861c3c9a46f9352bfee
parent3bb06ad910389fcfc886bd5156f41dc73f307f80
signature Signed by PGP key B5690EEEBB952194

fix(mobile): use native settings and snooze controls (#12512)


10 files changed, 284 insertions(+), 244 deletions(-)

apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx+1-1
......@@ -26,7 +26,7 @@ export function CodeAppearanceSection() {
2626 );
2727
2828 return (
29 <SettingsSection card title="Code & Diffs">
29 <SettingsSection title="Code & Diffs">
3030 <CodeAppearancePreview
3131 fontSize={appearance.codeFontSize}
3232 wordBreak={appearance.codeWordBreak}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+1-1
......@@ -26,7 +26,7 @@ export function TerminalAppearanceSection() {
2626 );
2727
2828 return (
29 <SettingsSection card title="Terminal">
29 <SettingsSection title="Terminal">
3030 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
3131 <AppearancePreviewSeparator />
3232 <SettingsSwitchRow
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+1-1
......@@ -15,7 +15,7 @@ export function TextAppearanceSection() {
1515 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
1616
1717 return (
18 <SettingsSection card title="Text">
18 <SettingsSection title="Text">
1919 <TextAppearancePreview fontSize={appearance.baseFontSize} />
2020 <AppearancePreviewSeparator />
2121 <FontSizeSliderRow
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-5
......@@ -7,8 +7,6 @@ export function SettingsSection(props: {
77 readonly title?: string;
88 readonly trailing?: ReactNode;
99 readonly children: ReactNode;
10 /** Force the grouped card background; Android otherwise lists options flat. */
11 readonly card?: boolean;
1210}) {
1311 return (
1412 <View className="gap-2">
......@@ -30,9 +28,7 @@ export function SettingsSection(props: {
3028 className={
3129 Platform.OS === "android"
3230 ? "overflow-hidden rounded-[28px] bg-card"
33 : props.card
34 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
35 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
31 : "overflow-hidden rounded-[24px] border-continuous bg-card"
3632 }
3733 >
3834 {props.children}
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx+4-8
......@@ -1,7 +1,6 @@
11import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";
22import {
33 BasicAlertDialog,
4 Button,
54 Column,
65 DateTimePicker,
76 Host,
......@@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) {
240239 <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}>
241240 <TextButton
242241 onClick={props.onClose}
243 colors={{ contentColor: colors["--color-primary"] }}
242 colors={{ contentColor: colors["--color-foreground"] }}
244243 >
245244 <Text style={bodyTypography}>Cancel</Text>
246245 </TextButton>
247 <Button
246 <TextButton
248247 onClick={submit}
249 colors={{
250 containerColor: colors["--color-primary"],
251 contentColor: colors["--color-primary-foreground"],
252 }}
248 colors={{ contentColor: colors["--color-foreground"] }}
253249 >
254250 <Text style={bodyTypography}>Snooze</Text>
255 </Button>
251 </TextButton>
256252 </Row>
257253 </Column>
258254 </Column>
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+172-124
......@@ -1,27 +1,22 @@
11import {
2 Button,
32 DatePicker,
43 Host,
54 HStack,
65 Picker,
76 Popover,
7 RNHostView,
88 Spacer,
99 Text,
1010 VStack,
1111} from "@expo/ui/swift-ui";
1212import {
13 accessibilityAddTraits,
1413 accessibilityHidden,
15 buttonBorderShape,
16 buttonStyle,
1714 clipped,
18 controlSize,
1915 font,
2016 datePickerStyle,
2117 foregroundStyle,
2218 frame,
2319 labelsHidden,
24 labelStyle,
2520 padding,
2621 pickerStyle,
2722 tag,
......@@ -32,12 +27,17 @@ import {
3227 resolveCustomSnooze,
3328 type CustomSnoozeInput,
3429} from "@t3tools/client-runtime/state/thread-settled";
35import { useState } from "react";
36import { useWindowDimensions } from "react-native";
30import { useState, type ReactNode } from "react";
31import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
32import { createNativeStackNavigator } from "@react-navigation/native-stack";
33import { ScrollView, useWindowDimensions, View } from "react-native";
34import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
35import { NativeHeaderToolbar } from "../../native/StackHeader";
3736import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3837import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3938
4039const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
40const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>();
4141const modes = [
4242 { value: "date", label: "Date and time" },
4343 { value: "duration", label: "Duration" },
......@@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: {
5252 readonly onClose: () => void;
5353 readonly onSnooze: (snoozedUntil: string) => void;
5454}) {
55 const { width } = useWindowDimensions();
55 const { width, height } = useWindowDimensions();
5656 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
5757 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
5858 const [amount, setAmount] = useState(2);
5959 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
6060 const [error, setError] = useState<string | null>(null);
6161 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
62 const popoverWidth = Math.min(360, width - 32);
63 const popoverHeight = Math.min(error ? 364 : 324, height - 96);
6264 const updateDate = (value: Date) => {
6365 setDate(value);
6466 setError(null);
......@@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: {
98100 />
99101 </Popover.Trigger>
100102 <Popover.Content>
101 <VStack
102 spacing={16}
103 modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]}
103 <SnoozePopoverNavigation
104 width={popoverWidth}
105 height={popoverHeight}
106 onClose={props.onClose}
107 onSubmit={submit}
104108 >
105 <HStack spacing={12}>
106 <Button
107 label="Cancel"
108 systemImage="xmark"
109 role="cancel"
110 onPress={props.onClose}
111 modifiers={[
112 labelStyle("iconOnly"),
113 buttonStyle(NATIVE_LIQUID_GLASS_SUPPORTED ? "glass" : "bordered"),
114 buttonBorderShape("circle"),
115 controlSize("large"),
116 ]}
117 />
118 <Text
119 modifiers={[
120 font({ textStyle: "headline" }),
121 frame({ maxWidth: Infinity, alignment: "leading" }),
122 accessibilityAddTraits(["isHeader"]),
123 ]}
124 >
125 Custom snooze
126 </Text>
127 <Button
128 label="Snooze"
129 onPress={submit}
130 modifiers={[
131 buttonStyle(
132 NATIVE_LIQUID_GLASS_SUPPORTED ? "glassProminent" : "borderedProminent",
133 ),
134 controlSize("large"),
135 ]}
136 />
137 </HStack>
138 <Picker
139 label="Snooze mode"
140 selection={mode}
141 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
142 setMode(value);
143 setError(null);
144 }}
145 modifiers={[pickerStyle("segmented")]}
109 <Host
110 matchContents={{ vertical: true }}
111 colorScheme={themeAppearance}
112 style={{ width: popoverWidth }}
146113 >
147 {modes.map((option) => (
148 <Text key={option.value} modifiers={[tag(option.value)]}>
149 {option.label}
150 </Text>
151 ))}
152 </Picker>
153 {mode === "date" ? (
154 <DatePicker
155 title="Snooze until"
156 selection={date}
157 displayedComponents={["date", "hourAndMinute"]}
158 onDateChange={updateDate}
159 modifiers={[
160 datePickerStyle("wheel"),
161 labelsHidden(),
162 frame({ maxWidth: Infinity, height: 180 }),
163 ]}
164 />
165 ) : (
166 <HStack spacing={0}>
167 <Picker
168 label="Duration amount"
169 selection={amount}
170 onSelectionChange={(value: number) => {
171 setAmount(value);
172 setError(null);
173 }}
174 modifiers={[
175 pickerStyle("wheel"),
176 labelsHidden(),
177 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
178 clipped(),
179 ]}
180 >
181 {durationAmounts.map((value) => (
182 <Text
183 key={value}
184 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
185 >
186 {String(value)}
187 </Text>
188 ))}
189 </Picker>
114 <VStack
115 spacing={16}
116 modifiers={[padding({ all: 16 }), foregroundStyle(colors["--color-foreground"])]}
117 >
190118 <Picker
191 label="Duration unit"
192 selection={unit}
193 onSelectionChange={(value: typeof unit) => {
194 setUnit(value);
119 label="Snooze mode"
120 selection={mode}
121 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
122 setMode(value);
195123 setError(null);
196124 }}
197 modifiers={[
198 pickerStyle("wheel"),
199 labelsHidden(),
200 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
201 clipped(),
202 ]}
125 modifiers={[pickerStyle("segmented")]}
203126 >
204 {units.map((option) => (
205 <Text
206 key={option.value}
207 modifiers={[tag(option.value), foregroundStyle(colors["--color-foreground"])]}
208 >
127 {modes.map((option) => (
128 <Text key={option.value} modifiers={[tag(option.value)]}>
209129 {option.label}
210130 </Text>
211131 ))}
212132 </Picker>
213 </HStack>
214 )}
215 {error ? (
216 <Text
217 modifiers={[
218 font({ textStyle: "footnote" }),
219 foregroundStyle(colors["--color-danger-foreground"]),
220 ]}
221 >
222 {error}
223 </Text>
224 ) : null}
225 </VStack>
133 {mode === "date" ? (
134 <DatePicker
135 title="Snooze until"
136 selection={date}
137 displayedComponents={["date", "hourAndMinute"]}
138 onDateChange={updateDate}
139 modifiers={[
140 datePickerStyle("wheel"),
141 labelsHidden(),
142 frame({ maxWidth: Infinity, height: 180 }),
143 ]}
144 />
145 ) : (
146 <HStack spacing={0}>
147 <Picker
148 label="Duration amount"
149 selection={amount}
150 onSelectionChange={(value: number) => {
151 setAmount(value);
152 setError(null);
153 }}
154 modifiers={[
155 pickerStyle("wheel"),
156 labelsHidden(),
157 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
158 clipped(),
159 ]}
160 >
161 {durationAmounts.map((value) => (
162 <Text
163 key={value}
164 modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]}
165 >
166 {String(value)}
167 </Text>
168 ))}
169 </Picker>
170 <Picker
171 label="Duration unit"
172 selection={unit}
173 onSelectionChange={(value: typeof unit) => {
174 setUnit(value);
175 setError(null);
176 }}
177 modifiers={[
178 pickerStyle("wheel"),
179 labelsHidden(),
180 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
181 clipped(),
182 ]}
183 >
184 {units.map((option) => (
185 <Text
186 key={option.value}
187 modifiers={[
188 tag(option.value),
189 foregroundStyle(colors["--color-foreground"]),
190 ]}
191 >
192 {option.label}
193 </Text>
194 ))}
195 </Picker>
196 </HStack>
197 )}
198 {error ? (
199 <Text
200 modifiers={[
201 font({ textStyle: "footnote" }),
202 foregroundStyle(colors["--color-danger-foreground"]),
203 ]}
204 >
205 {error}
206 </Text>
207 ) : null}
208 </VStack>
209 </Host>
210 </SnoozePopoverNavigation>
226211 </Popover.Content>
227212 </Popover>
228213 </Host>
229214 );
230215}
216
217/** The popover owns a native navigation bar, just like the app's sheet screens. */
218function SnoozePopoverNavigation(props: {
219 readonly width: number;
220 readonly height: number;
221 readonly onClose: () => void;
222 readonly onSubmit: () => void;
223 readonly children: ReactNode;
224}) {
225 const navigationTheme = useMobileNavigationTheme();
226 const { themeVariables: colors } = useAppearancePreferences();
227
228 return (
229 <VStack modifiers={[frame({ width: props.width, height: props.height })]}>
230 <RNHostView>
231 <View style={{ width: props.width, height: props.height }}>
232 <NavigationIndependentTree>
233 <NavigationContainer theme={navigationTheme}>
234 <SnoozeStack.Navigator
235 screenOptions={{
236 contentStyle: { backgroundColor: "transparent" },
237 headerShadowVisible: false,
238 headerStyle: {
239 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED
240 ? "transparent"
241 : colors["--color-card"],
242 },
243 headerTintColor: colors["--color-foreground"],
244 headerTitleStyle: { fontSize: 17, fontWeight: "700" },
245 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
246 title: "Custom snooze",
247 }}
248 >
249 <SnoozeStack.Screen name="CustomSnooze">
250 {() => (
251 <>
252 <NativeHeaderToolbar placement="left">
253 <NativeHeaderToolbar.Button
254 accessibilityLabel="Cancel custom snooze"
255 icon="xmark"
256 onPress={props.onClose}
257 />
258 </NativeHeaderToolbar>
259 <NativeHeaderToolbar placement="right">
260 <NativeHeaderToolbar.Button label="Snooze" onPress={props.onSubmit} />
261 </NativeHeaderToolbar>
262 <ScrollView
263 contentInsetAdjustmentBehavior="automatic"
264 showsVerticalScrollIndicator={false}
265 >
266 {props.children}
267 </ScrollView>
268 </>
269 )}
270 </SnoozeStack.Screen>
271 </SnoozeStack.Navigator>
272 </NavigationContainer>
273 </NavigationIndependentTree>
274 </View>
275 </RNHostView>
276 </VStack>
277 );
278}
apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx+2-2
......@@ -152,7 +152,7 @@ export function CustomSnoozeSheet(props: {
152152 </Pressable>
153153 <Pressable
154154 accessibilityRole="button"
155 className="min-h-12 justify-center rounded-xl bg-primary px-3"
155 className="min-h-12 justify-center rounded-xl bg-subtle px-3"
156156 onPress={() => {
157157 const input: CustomSnoozeInput =
158158 mode === "date"
......@@ -171,7 +171,7 @@ export function CustomSnoozeSheet(props: {
171171 props.onClose();
172172 }}
173173 >
174 <AppText className="text-primary-foreground">Snooze</AppText>
174 <AppText className="text-foreground">Snooze</AppText>
175175 </Pressable>
176176 </View>
177177 </ScrollView>
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-98
......@@ -9,7 +9,6 @@ import type {
99import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
1010import { useAtomSet, useAtomValue } from "@effect/atom-react";
1111import { AnimatedLegendList } from "@legendapp/list/reanimated";
12import { HeaderHeightContext } from "@react-navigation/elements";
1312import {
1413 getProviderOptionCurrentLabel,
1514 getProviderOptionCurrentValue,
......@@ -31,13 +30,22 @@ import {
3130 useState,
3231 type ReactNode,
3332} from "react";
34import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";
33import {
34 Alert,
35 Platform,
36 Pressable,
37 RefreshControl,
38 ScrollView,
39 TextInput,
40 View,
41} from "react-native";
3542import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
3643import { useSafeAreaInsets } from "react-native-safe-area-context";
3744
3845import { SymbolView } from "../../components/AppSymbol";
3946import { AppText as Text } from "../../components/AppText";
4047import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
48import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
4149import { MaterialButton } from "../../components/MaterialButton";
4250import { MaterialIconButton } from "../../components/MaterialIconButton";
4351import { ProviderIcon } from "../../components/ProviderIcon";
......@@ -759,12 +767,27 @@ function ThreadSettingsMainContent(props: {
759767 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
760768}) {
761769 const session = useThreadSettingsSession();
770 const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
771 reportFailure: false,
772 });
773 const refreshProviderCatalog = useMemo(
774 () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
775 [refreshProvidersCommand],
776 );
777 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
778 const refreshProviders = useCallback(() => {
779 if (!session.environmentId || isRefreshingProviders) return;
780 setIsRefreshingProviders(true);
781 void refreshProviderCatalog(session.environmentId).then((result) => {
782 setIsRefreshingProviders(false);
783 const error = providerCatalogRefreshError(result);
784 if (error) Alert.alert("Could not refresh models", error);
785 });
786 }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
762787 const catalogItems = useThreadSettingsCatalogItems(session);
763788 const [animationsReady, setAnimationsReady] = useState(false);
764 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
765789 const hasActiveCatalogFilter =
766790 session.providerFilter !== null || session.searchQuery.trim().length > 0;
767 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
768791 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(
769792 () => [
770793 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
......@@ -830,6 +853,7 @@ function ThreadSettingsMainContent(props: {
830853
831854 return (
832855 <AnimatedLegendList
856 alwaysBounceVertical
833857 automaticallyAdjustsScrollIndicatorInsets
834858 className="flex-1 bg-sheet"
835859 style={
......@@ -838,7 +862,7 @@ function ThreadSettingsMainContent(props: {
838862 : undefined
839863 }
840864 contentContainerStyle={{ paddingTop: 4 }}
841 contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"}
865 contentInsetAdjustmentBehavior="automatic"
842866 data={listItems}
843867 estimatedItemSize={Platform.OS === "android" ? 56 : 48}
844868 extraData={animationsReady}
......@@ -850,7 +874,6 @@ function ThreadSettingsMainContent(props: {
850874 maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION}
851875 ListHeaderComponent={
852876 <>
853 {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null}
854877 {Platform.OS === "android" ? (
855878 <View className="px-4 pb-2 pt-3">
856879 <View
......@@ -886,43 +909,16 @@ function ThreadSettingsMainContent(props: {
886909 />
887910 ) : null}
888911 </View>
889 <View className="flex-row gap-2 pt-3">
890 <Pressable
891 accessibilityRole="button"
892 accessibilityState={{
893 selected: session.providerFilter !== FAVORITES_PROVIDER_FILTER,
894 }}
895 className={cn(
896 "rounded-full px-4 py-2",
897 session.providerFilter !== FAVORITES_PROVIDER_FILTER
898 ? "bg-subtle-strong"
899 : "bg-subtle",
900 )}
901 onPress={() => session.setProviderFilter(null)}
902 >
903 <Text className="text-sm font-t3-medium text-foreground">All</Text>
904 </Pressable>
905 <Pressable
906 accessibilityRole="button"
907 accessibilityState={{
908 selected: session.providerFilter === FAVORITES_PROVIDER_FILTER,
909 }}
910 className={cn(
911 "rounded-full px-4 py-2",
912 session.providerFilter === FAVORITES_PROVIDER_FILTER
913 ? "bg-subtle-strong"
914 : "bg-subtle",
915 )}
916 onPress={() => session.setProviderFilter(FAVORITES_PROVIDER_FILTER)}
917 >
918 <Text className="text-sm font-t3-medium text-foreground">Favorites</Text>
919 </Pressable>
920 </View>
921912 </View>
922913 ) : null}
923914 </>
924915 }
925916 recycleItems
917 refreshControl={
918 session.environmentId ? (
919 <RefreshControl refreshing={isRefreshingProviders} onRefresh={refreshProviders} />
920 ) : undefined
921 }
926922 onLoad={() => setAnimationsReady(true)}
927923 renderItem={renderCatalogItem}
928924 showsVerticalScrollIndicator={false}
......@@ -1042,27 +1038,22 @@ function ThreadSettingsModelsScreen() {
10421038 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
10431039 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
10441040 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
1045 const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
1046 reportFailure: false,
1047 });
1048 const refreshProviderCatalog = useMemo(
1049 () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
1050 [refreshProvidersCommand],
1051 );
1052 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
1053 const refreshProviders = useCallback(() => {
1054 if (!session.environmentId || isRefreshingProviders) return;
1055 setIsRefreshingProviders(true);
1056 void refreshProviderCatalog(session.environmentId).then((result) => {
1057 setIsRefreshingProviders(false);
1058 const error = providerCatalogRefreshError(result);
1059 if (error) Alert.alert("Could not refresh models", error);
1060 });
1061 }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
10621041 const commitAndClose = useCallback(() => {
10631042 if (!session.commitPendingModel()) return;
10641043 presentation.onClose();
10651044 }, [presentation, session]);
1045 const providerFilters = useMemo(
1046 () => [
1047 { id: "all-providers", title: "All providers", value: null },
1048 { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER },
1049 ...session.providerGroups.map((group) => ({
1050 id: `provider:${group.providerKey}`,
1051 title: group.providerLabel,
1052 value: group.providerKey,
1053 })),
1054 ],
1055 [session.providerGroups],
1056 );
10661057 const filterMenu = useMemo(
10671058 () => ({
10681059 title: "Model filters",
......@@ -1070,30 +1061,12 @@ function ThreadSettingsModelsScreen() {
10701061 {
10711062 type: "submenu" as const,
10721063 title: "Provider",
1073 items: [
1074 {
1075 type: "action" as const,
1076 title: "All providers",
1077 state: session.providerFilter === null ? ("on" as const) : ("off" as const),
1078 onPress: () => session.setProviderFilter(null),
1079 },
1080 {
1081 type: "action" as const,
1082 title: "Favorites",
1083 state:
1084 session.providerFilter === FAVORITES_PROVIDER_FILTER
1085 ? ("on" as const)
1086 : ("off" as const),
1087 onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER),
1088 },
1089 ...session.providerGroups.map((group) => ({
1090 type: "action" as const,
1091 title: group.providerLabel,
1092 state:
1093 session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const),
1094 onPress: () => session.setProviderFilter(group.providerKey),
1095 })),
1096 ],
1064 items: providerFilters.map((filter) => ({
1065 type: "action" as const,
1066 title: filter.title,
1067 state: session.providerFilter === filter.value ? ("on" as const) : ("off" as const),
1068 onPress: () => session.setProviderFilter(filter.value),
1069 })),
10971070 },
10981071 ...(session.hasLegacyModels
10991072 ? [
......@@ -1107,25 +1080,59 @@ function ThreadSettingsModelsScreen() {
11071080 : []),
11081081 ],
11091082 }),
1110 [session],
1083 [providerFilters, session],
11111084 );
11121085
11131086 return (
11141087 <>
11151088 {Platform.OS === "android" ? (
11161089 <AndroidScreenHeader
1117 actions={[
1118 {
1119 accessibilityLabel: "Refresh models",
1120 disabled: isRefreshingProviders || session.environmentId === null,
1121 icon: "arrow.clockwise",
1122 onPress: refreshProviders,
1123 },
1124 ]}
11251090 trailing={
1126 session.pendingModel ? (
1127 <MaterialButton label="Save" tone="text" onPress={commitAndClose} />
1128 ) : undefined
1091 <View className="flex-row items-center">
1092 <AndroidAnchoredMenu
1093 title="Model filters"
1094 actions={[
1095 {
1096 title: "Provider",
1097 subactions: providerFilters.map((filter) => ({
1098 id: filter.id,
1099 title: filter.title,
1100 state: session.providerFilter === filter.value ? "on" : "off",
1101 })),
1102 },
1103 ...(session.hasLegacyModels
1104 ? [
1105 {
1106 id: "show-legacy",
1107 title: "Show legacy models",
1108 state: session.showLegacy ? ("on" as const) : ("off" as const),
1109 },
1110 ]
1111 : []),
1112 ]}
1113 onPressAction={({ nativeEvent }) => {
1114 if (nativeEvent.event === "show-legacy") {
1115 session.setShowLegacy(!session.showLegacy);
1116 } else {
1117 const filter = providerFilters.find((item) => item.id === nativeEvent.event);
1118 if (filter) session.setProviderFilter(filter.value);
1119 }
1120 }}
1121 >
1122 {(open) => (
1123 <MaterialIconButton
1124 accessibilityLabel="Filter models"
1125 icon="line.3.horizontal.decrease"
1126 selected={hasCustomCatalogFilter}
1127 variant={hasCustomCatalogFilter ? "tonal" : "standard"}
1128 onPress={open}
1129 />
1130 )}
1131 </AndroidAnchoredMenu>
1132 {session.pendingModel ? (
1133 <MaterialButton label="Save" tone="text" onPress={commitAndClose} />
1134 ) : null}
1135 </View>
11291136 }
11301137 onBack={presentation.onClose}
11311138 title="Thread settings"
......@@ -1189,13 +1196,6 @@ function ThreadSettingsModelsScreen() {
11891196 />
11901197 </NativeHeaderToolbar>
11911198 <NativeHeaderToolbar placement="right">
1192 <NativeHeaderToolbar.Button
1193 accessibilityLabel="Refresh models"
1194 disabled={isRefreshingProviders || session.environmentId === null}
1195 icon="arrow.clockwise"
1196 onPress={refreshProviders}
1197 separateBackground
1198 />
11991199 <NativeHeaderToolbar.Button
12001200 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}
12011201 label={session.pendingModel ? "Save" : "Done"}
apps/mobile/src/features/threads/provider-catalog-refresh.ts+1-1
......@@ -10,7 +10,7 @@ type RefreshProvidersTarget = {
1010 readonly input: { readonly refreshModels: true };
1111};
1212
13/** Explicit model-picker refresh. Repeated taps share the pending discovery. */
13/** Pull-to-refresh in the model picker shares any pending discovery. */
1414export function createProviderCatalogRefreshRunner<Result>(
1515 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,
1616) {
apps/mobile/src/features/usage/UsageRouteScreen.tsx+3-3
......@@ -412,7 +412,7 @@ function ProviderSection(props: {
412412 );
413413
414414 return (
415 <SettingsSection title="Providers" card>
415 <SettingsSection title="Providers">
416416 {ordered.map((provider, index) => {
417417 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
418418 return (
......@@ -463,7 +463,7 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
463463 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
464464
465465 return (
466 <SettingsSection title="Totals" card>
466 <SettingsSection title="Totals">
467467 <View className="flex-row flex-wrap">
468468 <MetricCell
469469 label="Processed tokens"
......@@ -524,7 +524,7 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
524524 if (merged.models.length === 0) return null;
525525
526526 return (
527 <SettingsSection title="By model" card>
527 <SettingsSection title="By model">
528528 {merged.models.map((model, index) => (
529529 <View
530530 key={`${model.provider}:${model.model}`}