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() {...@@ -26,7 +26,7 @@ export function CodeAppearanceSection() {
26 );26 );
2727
28 return (28 return (
29 <SettingsSection card title="Code & Diffs">29 <SettingsSection title="Code & Diffs">
30 <CodeAppearancePreview30 <CodeAppearancePreview
31 fontSize={appearance.codeFontSize}31 fontSize={appearance.codeFontSize}
32 wordBreak={appearance.codeWordBreak}32 wordBreak={appearance.codeWordBreak}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+1-1
...@@ -26,7 +26,7 @@ export function TerminalAppearanceSection() {...@@ -26,7 +26,7 @@ export function TerminalAppearanceSection() {
26 );26 );
2727
28 return (28 return (
29 <SettingsSection card title="Terminal">29 <SettingsSection title="Terminal">
30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
31 <AppearancePreviewSeparator />31 <AppearancePreviewSeparator />
32 <SettingsSwitchRow32 <SettingsSwitchRow
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+1-1
...@@ -15,7 +15,7 @@ export function TextAppearanceSection() {...@@ -15,7 +15,7 @@ export function TextAppearanceSection() {
15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
1616
17 return (17 return (
18 <SettingsSection card title="Text">18 <SettingsSection title="Text">
19 <TextAppearancePreview fontSize={appearance.baseFontSize} />19 <TextAppearancePreview fontSize={appearance.baseFontSize} />
20 <AppearancePreviewSeparator />20 <AppearancePreviewSeparator />
21 <FontSizeSliderRow21 <FontSizeSliderRow
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-5
...@@ -7,8 +7,6 @@ export function SettingsSection(props: {...@@ -7,8 +7,6 @@ export function SettingsSection(props: {
7 readonly title?: string;7 readonly title?: string;
8 readonly trailing?: ReactNode;8 readonly trailing?: ReactNode;
9 readonly children: ReactNode;9 readonly children: ReactNode;
10 /** Force the grouped card background; Android otherwise lists options flat. */
11 readonly card?: boolean;
12}) {10}) {
13 return (11 return (
14 <View className="gap-2">12 <View className="gap-2">
...@@ -30,9 +28,7 @@ export function SettingsSection(props: {...@@ -30,9 +28,7 @@ export function SettingsSection(props: {
30 className={28 className={
31 Platform.OS === "android"29 Platform.OS === "android"
32 ? "overflow-hidden rounded-[28px] bg-card"30 ? "overflow-hidden rounded-[28px] bg-card"
33 : props.card31 : "overflow-hidden rounded-[24px] border-continuous bg-card"
34 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
35 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
36 }32 }
37 >33 >
38 {props.children}34 {props.children}
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx+4-8
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";1import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";
2import {2import {
3 BasicAlertDialog,3 BasicAlertDialog,
4 Button,
5 Column,4 Column,
6 DateTimePicker,5 DateTimePicker,
7 Host,6 Host,
...@@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) {...@@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) {
240 <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}>239 <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}>
241 <TextButton240 <TextButton
242 onClick={props.onClose}241 onClick={props.onClose}
243 colors={{ contentColor: colors["--color-primary"] }}242 colors={{ contentColor: colors["--color-foreground"] }}
244 >243 >
245 <Text style={bodyTypography}>Cancel</Text>244 <Text style={bodyTypography}>Cancel</Text>
246 </TextButton>245 </TextButton>
247 <Button246 <TextButton
248 onClick={submit}247 onClick={submit}
249 colors={{248 colors={{ contentColor: colors["--color-foreground"] }}
250 containerColor: colors["--color-primary"],
251 contentColor: colors["--color-primary-foreground"],
252 }}
253 >249 >
254 <Text style={bodyTypography}>Snooze</Text>250 <Text style={bodyTypography}>Snooze</Text>
255 </Button>251 </TextButton>
256 </Row>252 </Row>
257 </Column>253 </Column>
258 </Column>254 </Column>
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+172-124
...@@ -1,27 +1,22 @@...@@ -1,27 +1,22 @@
1import {1import {
2 Button,
3 DatePicker,2 DatePicker,
4 Host,3 Host,
5 HStack,4 HStack,
6 Picker,5 Picker,
7 Popover,6 Popover,
7 RNHostView,
8 Spacer,8 Spacer,
9 Text,9 Text,
10 VStack,10 VStack,
11} from "@expo/ui/swift-ui";11} from "@expo/ui/swift-ui";
12import {12import {
13 accessibilityAddTraits,
14 accessibilityHidden,13 accessibilityHidden,
15 buttonBorderShape,
16 buttonStyle,
17 clipped,14 clipped,
18 controlSize,
19 font,15 font,
20 datePickerStyle,16 datePickerStyle,
21 foregroundStyle,17 foregroundStyle,
22 frame,18 frame,
23 labelsHidden,19 labelsHidden,
24 labelStyle,
25 padding,20 padding,
26 pickerStyle,21 pickerStyle,
27 tag,22 tag,
...@@ -32,12 +27,17 @@ import {...@@ -32,12 +27,17 @@ import {
32 resolveCustomSnooze,27 resolveCustomSnooze,
33 type CustomSnoozeInput,28 type CustomSnoozeInput,
34} from "@t3tools/client-runtime/state/thread-settled";29} from "@t3tools/client-runtime/state/thread-settled";
35import { useState } from "react";30import { useState, type ReactNode } from "react";
36import { useWindowDimensions } from "react-native";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";
37import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";36import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
38import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";37import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3938
40const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);39const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
40const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>();
41const modes = [41const modes = [
42 { value: "date", label: "Date and time" },42 { value: "date", label: "Date and time" },
43 { value: "duration", label: "Duration" },43 { value: "duration", label: "Duration" },
...@@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: {...@@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: {
52 readonly onClose: () => void;52 readonly onClose: () => void;
53 readonly onSnooze: (snoozedUntil: string) => void;53 readonly onSnooze: (snoozedUntil: string) => void;
54}) {54}) {
55 const { width } = useWindowDimensions();55 const { width, height } = useWindowDimensions();
56 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");56 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
57 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));57 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
58 const [amount, setAmount] = useState(2);58 const [amount, setAmount] = useState(2);
59 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");59 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
60 const [error, setError] = useState<string | null>(null);60 const [error, setError] = useState<string | null>(null);
61 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();61 const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
62 const popoverWidth = Math.min(360, width - 32);
63 const popoverHeight = Math.min(error ? 364 : 324, height - 96);
62 const updateDate = (value: Date) => {64 const updateDate = (value: Date) => {
63 setDate(value);65 setDate(value);
64 setError(null);66 setError(null);
...@@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: {...@@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: {
98 />100 />
99 </Popover.Trigger>101 </Popover.Trigger>
100 <Popover.Content>102 <Popover.Content>
101 <VStack103 <SnoozePopoverNavigation
102 spacing={16}104 width={popoverWidth}
103 modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]}105 height={popoverHeight}
106 onClose={props.onClose}
107 onSubmit={submit}
104 >108 >
105 <HStack spacing={12}>109 <Host
106 <Button110 matchContents={{ vertical: true }}
107 label="Cancel"111 colorScheme={themeAppearance}
108 systemImage="xmark"112 style={{ width: popoverWidth }}
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")]}
146 >113 >
147 {modes.map((option) => (114 <VStack
148 <Text key={option.value} modifiers={[tag(option.value)]}>115 spacing={16}
149 {option.label}116 modifiers={[padding({ all: 16 }), foregroundStyle(colors["--color-foreground"])]}
150 </Text>117 >
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>
190 <Picker118 <Picker
191 label="Duration unit"119 label="Snooze mode"
192 selection={unit}120 selection={mode}
193 onSelectionChange={(value: typeof unit) => {121 onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
194 setUnit(value);122 setMode(value);
195 setError(null);123 setError(null);
196 }}124 }}
197 modifiers={[125 modifiers={[pickerStyle("segmented")]}
198 pickerStyle("wheel"),
199 labelsHidden(),
200 frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
201 clipped(),
202 ]}
203 >126 >
204 {units.map((option) => (127 {modes.map((option) => (
205 <Text128 <Text key={option.value} modifiers={[tag(option.value)]}>
206 key={option.value}
207 modifiers={[tag(option.value), foregroundStyle(colors["--color-foreground"])]}
208 >
209 {option.label}129 {option.label}
210 </Text>130 </Text>
211 ))}131 ))}
212 </Picker>132 </Picker>
213 </HStack>133 {mode === "date" ? (
214 )}134 <DatePicker
215 {error ? (135 title="Snooze until"
216 <Text136 selection={date}
217 modifiers={[137 displayedComponents={["date", "hourAndMinute"]}
218 font({ textStyle: "footnote" }),138 onDateChange={updateDate}
219 foregroundStyle(colors["--color-danger-foreground"]),139 modifiers={[
220 ]}140 datePickerStyle("wheel"),
221 >141 labelsHidden(),
222 {error}142 frame({ maxWidth: Infinity, height: 180 }),
223 </Text>143 ]}
224 ) : null}144 />
225 </VStack>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>
226 </Popover.Content>211 </Popover.Content>
227 </Popover>212 </Popover>
228 </Host>213 </Host>
229 );214 );
230}215}
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: {...@@ -152,7 +152,7 @@ export function CustomSnoozeSheet(props: {
152 </Pressable>152 </Pressable>
153 <Pressable153 <Pressable
154 accessibilityRole="button"154 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"
156 onPress={() => {156 onPress={() => {
157 const input: CustomSnoozeInput =157 const input: CustomSnoozeInput =
158 mode === "date"158 mode === "date"
...@@ -171,7 +171,7 @@ export function CustomSnoozeSheet(props: {...@@ -171,7 +171,7 @@ export function CustomSnoozeSheet(props: {
171 props.onClose();171 props.onClose();
172 }}172 }}
173 >173 >
174 <AppText className="text-primary-foreground">Snooze</AppText>174 <AppText className="text-foreground">Snooze</AppText>
175 </Pressable>175 </Pressable>
176 </View>176 </View>
177 </ScrollView>177 </ScrollView>
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-98
...@@ -9,7 +9,6 @@ import type {...@@ -9,7 +9,6 @@ import type {
9import type { LegendListRenderItemProps } from "@legendapp/list/react-native";9import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
10import { useAtomSet, useAtomValue } from "@effect/atom-react";10import { useAtomSet, useAtomValue } from "@effect/atom-react";
11import { AnimatedLegendList } from "@legendapp/list/reanimated";11import { AnimatedLegendList } from "@legendapp/list/reanimated";
12import { HeaderHeightContext } from "@react-navigation/elements";
13import {12import {
14 getProviderOptionCurrentLabel,13 getProviderOptionCurrentLabel,
15 getProviderOptionCurrentValue,14 getProviderOptionCurrentValue,
...@@ -31,13 +30,22 @@ import {...@@ -31,13 +30,22 @@ import {
31 useState,30 useState,
32 type ReactNode,31 type ReactNode,
33} from "react";32} 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";
35import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";42import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
36import { useSafeAreaInsets } from "react-native-safe-area-context";43import { useSafeAreaInsets } from "react-native-safe-area-context";
3744
38import { SymbolView } from "../../components/AppSymbol";45import { SymbolView } from "../../components/AppSymbol";
39import { AppText as Text } from "../../components/AppText";46import { AppText as Text } from "../../components/AppText";
40import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";47import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
48import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
41import { MaterialButton } from "../../components/MaterialButton";49import { MaterialButton } from "../../components/MaterialButton";
42import { MaterialIconButton } from "../../components/MaterialIconButton";50import { MaterialIconButton } from "../../components/MaterialIconButton";
43import { ProviderIcon } from "../../components/ProviderIcon";51import { ProviderIcon } from "../../components/ProviderIcon";
...@@ -759,12 +767,27 @@ function ThreadSettingsMainContent(props: {...@@ -759,12 +767,27 @@ function ThreadSettingsMainContent(props: {
759 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;767 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
760}) {768}) {
761 const session = useThreadSettingsSession();769 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]);
762 const catalogItems = useThreadSettingsCatalogItems(session);787 const catalogItems = useThreadSettingsCatalogItems(session);
763 const [animationsReady, setAnimationsReady] = useState(false);788 const [animationsReady, setAnimationsReady] = useState(false);
764 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
765 const hasActiveCatalogFilter =789 const hasActiveCatalogFilter =
766 session.providerFilter !== null || session.searchQuery.trim().length > 0;790 session.providerFilter !== null || session.searchQuery.trim().length > 0;
767 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
768 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(791 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(
769 () => [792 () => [
770 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),793 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
...@@ -830,6 +853,7 @@ function ThreadSettingsMainContent(props: {...@@ -830,6 +853,7 @@ function ThreadSettingsMainContent(props: {
830853
831 return (854 return (
832 <AnimatedLegendList855 <AnimatedLegendList
856 alwaysBounceVertical
833 automaticallyAdjustsScrollIndicatorInsets857 automaticallyAdjustsScrollIndicatorInsets
834 className="flex-1 bg-sheet"858 className="flex-1 bg-sheet"
835 style={859 style={
...@@ -838,7 +862,7 @@ function ThreadSettingsMainContent(props: {...@@ -838,7 +862,7 @@ function ThreadSettingsMainContent(props: {
838 : undefined862 : undefined
839 }863 }
840 contentContainerStyle={{ paddingTop: 4 }}864 contentContainerStyle={{ paddingTop: 4 }}
841 contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"}865 contentInsetAdjustmentBehavior="automatic"
842 data={listItems}866 data={listItems}
843 estimatedItemSize={Platform.OS === "android" ? 56 : 48}867 estimatedItemSize={Platform.OS === "android" ? 56 : 48}
844 extraData={animationsReady}868 extraData={animationsReady}
...@@ -850,7 +874,6 @@ function ThreadSettingsMainContent(props: {...@@ -850,7 +874,6 @@ function ThreadSettingsMainContent(props: {
850 maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION}874 maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION}
851 ListHeaderComponent={875 ListHeaderComponent={
852 <>876 <>
853 {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null}
854 {Platform.OS === "android" ? (877 {Platform.OS === "android" ? (
855 <View className="px-4 pb-2 pt-3">878 <View className="px-4 pb-2 pt-3">
856 <View879 <View
...@@ -886,43 +909,16 @@ function ThreadSettingsMainContent(props: {...@@ -886,43 +909,16 @@ function ThreadSettingsMainContent(props: {
886 />909 />
887 ) : null}910 ) : null}
888 </View>911 </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>
921 </View>912 </View>
922 ) : null}913 ) : null}
923 </>914 </>
924 }915 }
925 recycleItems916 recycleItems
917 refreshControl={
918 session.environmentId ? (
919 <RefreshControl refreshing={isRefreshingProviders} onRefresh={refreshProviders} />
920 ) : undefined
921 }
926 onLoad={() => setAnimationsReady(true)}922 onLoad={() => setAnimationsReady(true)}
927 renderItem={renderCatalogItem}923 renderItem={renderCatalogItem}
928 showsVerticalScrollIndicator={false}924 showsVerticalScrollIndicator={false}
...@@ -1042,27 +1038,22 @@ function ThreadSettingsModelsScreen() {...@@ -1042,27 +1038,22 @@ function ThreadSettingsModelsScreen() {
1042 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();1038 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1043 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;1039 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
1044 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;1040 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]);
1062 const commitAndClose = useCallback(() => {1041 const commitAndClose = useCallback(() => {
1063 if (!session.commitPendingModel()) return;1042 if (!session.commitPendingModel()) return;
1064 presentation.onClose();1043 presentation.onClose();
1065 }, [presentation, session]);1044 }, [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 );
1066 const filterMenu = useMemo(1057 const filterMenu = useMemo(
1067 () => ({1058 () => ({
1068 title: "Model filters",1059 title: "Model filters",
...@@ -1070,30 +1061,12 @@ function ThreadSettingsModelsScreen() {...@@ -1070,30 +1061,12 @@ function ThreadSettingsModelsScreen() {
1070 {1061 {
1071 type: "submenu" as const,1062 type: "submenu" as const,
1072 title: "Provider",1063 title: "Provider",
1073 items: [1064 items: providerFilters.map((filter) => ({
1074 {1065 type: "action" as const,
1075 type: "action" as const,1066 title: filter.title,
1076 title: "All providers",1067 state: session.providerFilter === filter.value ? ("on" as const) : ("off" as const),
1077 state: session.providerFilter === null ? ("on" as const) : ("off" as const),1068 onPress: () => session.setProviderFilter(filter.value),
1078 onPress: () => session.setProviderFilter(null),1069 })),
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 ],
1097 },1070 },
1098 ...(session.hasLegacyModels1071 ...(session.hasLegacyModels
1099 ? [1072 ? [
...@@ -1107,25 +1080,59 @@ function ThreadSettingsModelsScreen() {...@@ -1107,25 +1080,59 @@ function ThreadSettingsModelsScreen() {
1107 : []),1080 : []),
1108 ],1081 ],
1109 }),1082 }),
1110 [session],1083 [providerFilters, session],
1111 );1084 );
11121085
1113 return (1086 return (
1114 <>1087 <>
1115 {Platform.OS === "android" ? (1088 {Platform.OS === "android" ? (
1116 <AndroidScreenHeader1089 <AndroidScreenHeader
1117 actions={[
1118 {
1119 accessibilityLabel: "Refresh models",
1120 disabled: isRefreshingProviders || session.environmentId === null,
1121 icon: "arrow.clockwise",
1122 onPress: refreshProviders,
1123 },
1124 ]}
1125 trailing={1090 trailing={
1126 session.pendingModel ? (1091 <View className="flex-row items-center">
1127 <MaterialButton label="Save" tone="text" onPress={commitAndClose} />1092 <AndroidAnchoredMenu
1128 ) : undefined1093 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>
1129 }1136 }
1130 onBack={presentation.onClose}1137 onBack={presentation.onClose}
1131 title="Thread settings"1138 title="Thread settings"
...@@ -1189,13 +1196,6 @@ function ThreadSettingsModelsScreen() {...@@ -1189,13 +1196,6 @@ function ThreadSettingsModelsScreen() {
1189 />1196 />
1190 </NativeHeaderToolbar>1197 </NativeHeaderToolbar>
1191 <NativeHeaderToolbar placement="right">1198 <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 />
1199 <NativeHeaderToolbar.Button1199 <NativeHeaderToolbar.Button
1200 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}1200 accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"}
1201 label={session.pendingModel ? "Save" : "Done"}1201 label={session.pendingModel ? "Save" : "Done"}
apps/mobile/src/features/threads/provider-catalog-refresh.ts+1-1
...@@ -10,7 +10,7 @@ type RefreshProvidersTarget = {...@@ -10,7 +10,7 @@ type RefreshProvidersTarget = {
10 readonly input: { readonly refreshModels: true };10 readonly input: { readonly refreshModels: true };
11};11};
1212
13/** Explicit model-picker refresh. Repeated taps share the pending discovery. */13/** Pull-to-refresh in the model picker shares any pending discovery. */
14export function createProviderCatalogRefreshRunner<Result>(14export function createProviderCatalogRefreshRunner<Result>(
15 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,15 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,
16) {16) {
apps/mobile/src/features/usage/UsageRouteScreen.tsx+3-3
...@@ -412,7 +412,7 @@ function ProviderSection(props: {...@@ -412,7 +412,7 @@ function ProviderSection(props: {
412 );412 );
413413
414 return (414 return (
415 <SettingsSection title="Providers" card>415 <SettingsSection title="Providers">
416 {ordered.map((provider, index) => {416 {ordered.map((provider, index) => {
417 const share = metric === "cost" ? provider.costShare : provider.tokenShare;417 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
418 return (418 return (
...@@ -463,7 +463,7 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H...@@ -463,7 +463,7 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
463 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;463 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
464464
465 return (465 return (
466 <SettingsSection title="Totals" card>466 <SettingsSection title="Totals">
467 <View className="flex-row flex-wrap">467 <View className="flex-row flex-wrap">
468 <MetricCell468 <MetricCell
469 label="Processed tokens"469 label="Processed tokens"
...@@ -524,7 +524,7 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {...@@ -524,7 +524,7 @@ function ModelsSection(props: { readonly merged: MergedUsage }) {
524 if (merged.models.length === 0) return null;524 if (merged.models.length === 0) return null;
525525
526 return (526 return (
527 <SettingsSection title="By model" card>527 <SettingsSection title="By model">
528 {merged.models.map((model, index) => (528 {merged.models.map((model, index) => (
529 <View529 <View
530 key={`${model.provider}:${model.model}`}530 key={`${model.provider}:${model.model}`}