From 2c51dc587652dcb546a02873df96467d870ddc20 Mon Sep 17 00:00:00 2001 From: ronak-guliani Date: Mon, 16 Feb 2026 21:25:08 -0800 Subject: [PATCH] Revamp mobile model picker experience --- .../mobile/src/components/model-picker.tsx | 540 ++++++++++++------ packages/mobile/src/store/settings.ts | 172 +++++- 2 files changed, 516 insertions(+), 196 deletions(-) diff --git a/packages/mobile/src/components/model-picker.tsx b/packages/mobile/src/components/model-picker.tsx index a1b8a382c02f..28d351078418 100644 --- a/packages/mobile/src/components/model-picker.tsx +++ b/packages/mobile/src/components/model-picker.tsx @@ -1,24 +1,21 @@ -import { useMemo, useCallback, memo, useState } from "react" -import { View, Text, Pressable, SectionList, StyleSheet, Modal, useWindowDimensions, Platform } from "react-native" +import { useMemo, useCallback, memo, useState, useEffect } from "react" +import { View, Text, Pressable, FlatList, StyleSheet, Modal, useWindowDimensions, Platform, TextInput } from "react-native" import { BlurView } from "expo-blur" import { LiquidGlassView, isLiquidGlassSupported } from "@callstack/liquid-glass" import { useSafeAreaInsets } from "react-native-safe-area-context" import * as Haptics from "expo-haptics" -import { useSettings, modelName } from "../store/settings" +import { useSettings, modelName, modelKey } from "../store/settings" import { useTheme, type Theme } from "../theme" type ModelItem = { + key: string id: string name: string providerID: string providerName: string reasoning: boolean -} - -type ModelSection = { - providerID: string - providerName: string - data: ModelItem[] + favorite: boolean + removed: boolean } type Props = { @@ -26,17 +23,29 @@ type Props = { onClose: () => void } -const POPULAR_PROVIDERS = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] +const POPULAR_PROVIDERS = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] as const -function compareProviderOrder(a: ModelSection, b: ModelSection) { - const ai = POPULAR_PROVIDERS.indexOf(a.providerID) - const bi = POPULAR_PROVIDERS.indexOf(b.providerID) +function compareProviderOrder(aID: string, aName: string, bID: string, bName: string) { + const ai = POPULAR_PROVIDERS.indexOf(aID as (typeof POPULAR_PROVIDERS)[number]) + const bi = POPULAR_PROVIDERS.indexOf(bID as (typeof POPULAR_PROVIDERS)[number]) const aPopular = ai >= 0 const bPopular = bi >= 0 if (aPopular && !bPopular) return -1 if (!aPopular && bPopular) return 1 if (aPopular && bPopular && ai !== bi) return ai - bi - return a.providerName.localeCompare(b.providerName) + return aName.localeCompare(bName) +} + +function compareModelOrder(a: ModelItem, b: ModelItem) { + if (a.favorite && !b.favorite) return -1 + if (!a.favorite && b.favorite) return 1 + + const provider = compareProviderOrder(a.providerID, a.providerName, b.providerID, b.providerName) + if (provider !== 0) return provider + + const name = a.name.localeCompare(b.name) + if (name !== 0) return name + return a.id.localeCompare(b.id) } export function ModelPicker({ visible, onClose }: Props) { @@ -46,67 +55,82 @@ export function ModelPicker({ visible, onClose }: Props) { const providerData = useSettings((s) => s.providerData) const current = useSettings((s) => s.model) const setModel = useSettings((s) => s.setModel) + const favorites = useSettings((s) => s.favorites) + const removed = useSettings((s) => s.removed) + const toggleFavorite = useSettings((s) => s.toggleFavorite) + const removeModel = useSettings((s) => s.removeModel) + const restoreModel = useSettings((s) => s.restoreModel) const activeName = useSettings(modelName) - const [collapsed, setCollapsed] = useState>({}) + const [query, setQuery] = useState("") + const [showRemoved, setShowRemoved] = useState(false) - const sections = useMemo(() => { + useEffect(() => { + if (visible) return + setQuery("") + setShowRemoved(false) + }, [visible]) + + const allModels = useMemo(() => { if (!providerData) return [] const connected = new Set(providerData.connected) - const result: ModelSection[] = [] + const result: ModelItem[] = [] for (const provider of providerData.all) { if (!connected.has(provider.id)) continue - - const models: ModelItem[] = [] - for (const [modelKey, info] of Object.entries(provider.models)) { + const providerName = provider.name || provider.id + for (const [baseModelID, info] of Object.entries(provider.models)) { if (info.status === "deprecated") continue - const id = info.id || modelKey - models.push({ + const id = info.id || baseModelID + const key = modelKey({ providerID: provider.id, modelID: id }) + result.push({ + key, id, name: info.name || id, providerID: provider.id, - providerName: provider.name || provider.id, + providerName, reasoning: info.reasoning, + favorite: !!favorites[key], + removed: !!removed[key], }) } - - if (!models.length) continue - models.sort((a, b) => a.name.localeCompare(b.name)) - result.push({ - providerID: provider.id, - providerName: provider.name || provider.id, - data: models, - }) } - result.sort(compareProviderOrder) - return result - }, [providerData]) - - const selected = current ? `${current.providerID}:${current.modelID}` : null - const sectionCountByProvider = useMemo( - () => - Object.fromEntries( - sections.map((section) => [ - section.providerID, - section.data.length, - ]), - ) as Record, - [sections], - ) - const visibleSections = useMemo( - () => sections.map((section) => (collapsed[section.providerID] ? { ...section, data: [] } : section)), - [sections, collapsed], - ) + return result.sort(compareModelOrder) + }, [providerData, favorites, removed]) + + const models = useMemo(() => { + const value = query.trim().toLowerCase() + return allModels.filter((item) => { + if (!showRemoved && item.removed) return false + if (!value) return true + return `${item.name} ${item.id} ${item.providerName} ${item.providerID}`.toLowerCase().includes(value) + }) + }, [allModels, query, showRemoved]) + + const selected = current ? modelKey(current) : null + const favoriteCount = useMemo(() => allModels.filter((item) => item.favorite).length, [allModels]) + const removedCount = useMemo(() => allModels.filter((item) => item.removed).length, [allModels]) + + const popupWidth = Math.min(windowWidth - 24, 560) + const popupHeight = Math.min(windowHeight - insets.top - insets.bottom - 24, 700) const selectedProvider = useMemo(() => { if (!current || !providerData) return "Default" return providerData.all.find((provider) => provider.id === current.providerID)?.name || current.providerID }, [current, providerData]) + const emptyText = useMemo(() => { + if (!providerData) return "Loading models..." + if (!allModels.length) return "No models available" + if (query.trim()) return "No models match your search" + if (!showRemoved && removedCount > 0) return "No visible models. Show removed to restore." + return "No models available" + }, [allModels.length, providerData, query, removedCount, showRemoved]) + const handleSelect = useCallback( (item: ModelItem) => { + if (item.removed) return Haptics.selectionAsync() setModel({ providerID: item.providerID, modelID: item.id }) onClose() @@ -120,15 +144,24 @@ export function ModelPicker({ visible, onClose }: Props) { onClose() }, [onClose, setModel]) - const popupWidth = Math.min(Math.max(windowWidth * 0.64, 260), 380) - const popupHeight = Math.min(windowHeight * 0.52, 520) + const handleFavorite = useCallback((item: ModelItem) => { + Haptics.selectionAsync() + toggleFavorite({ providerID: item.providerID, modelID: item.id }) + }, [toggleFavorite]) + + const handleRemove = useCallback((item: ModelItem) => { + Haptics.selectionAsync() + removeModel({ providerID: item.providerID, modelID: item.id }) + }, [removeModel]) + + const handleRestore = useCallback((item: ModelItem) => { + Haptics.selectionAsync() + restoreModel({ providerID: item.providerID, modelID: item.id }) + }, [restoreModel]) - const toggleProvider = useCallback((providerID: string) => { + const handleRemovedToggle = useCallback(() => { Haptics.selectionAsync() - setCollapsed((state) => ({ - ...state, - [providerID]: !state[providerID], - })) + setShowRemoved((state) => !state) }, []) const Glass = LiquidGlassView as React.ComponentType<{ @@ -141,55 +174,84 @@ export function ModelPicker({ visible, onClose }: Props) { <> - - {activeName} + Choose model + + Current: {activeName} {selectedProvider} - {"\u25BE"} + + {"\u2715"} + - - Default - {!current && {"\u2713"}} - - - `${item.providerID}:${item.id}`} - renderSectionHeader={({ section }) => { - const isCollapsed = !!collapsed[section.providerID] - return ( - toggleProvider(section.providerID)} - > - {section.providerName} - - - {sectionCountByProvider[section.providerID] ?? 0} - - - {isCollapsed ? "\u25B8" : "\u25BE"} - - + + + {"\u2315"} + + {query.length > 0 && ( + setQuery("")} hitSlop={8} accessibilityRole="button" accessibilityLabel="Clear search"> + {"\u2715"} - ) - }} - renderItem={({ item }) => { - const key = `${item.providerID}:${item.id}` - return - }} + )} + + + + + + Default + {!current && {"\u2713"}} + + + + {showRemoved ? "Hide removed" : "Show removed"} + + {removedCount} + + + + + + {models.length} visible • {favoriteCount} favorites • {allModels.length} total + + + + item.key} + renderItem={({ item }) => ( + + )} ListEmptyComponent={ - sections.length === 0 ? ( - - - {providerData ? "No models available" : "Loading models..."} - - - ) : null + + {emptyText} + } style={styles.list} contentContainerStyle={styles.listContent} @@ -197,40 +259,41 @@ export function ModelPicker({ visible, onClose }: Props) { scrollEnabled nestedScrollEnabled showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} /> ) return ( - - - - - - {isLiquidGlassSupported ? ( - - {content} - - ) : ( - + + + + + + - {content} - - )} + {isLiquidGlassSupported ? ( + + {content} + + ) : ( + + {content} + + )} + + ) @@ -240,37 +303,79 @@ const PickerRow = memo(function PickerRow({ item, selected, onSelect, + onFavorite, + onRemove, + onRestore, theme, }: { item: ModelItem selected: boolean onSelect: (item: ModelItem) => void + onFavorite: (item: ModelItem) => void + onRemove: (item: ModelItem) => void + onRestore: (item: ModelItem) => void theme: Theme }) { + const removeLabel = item.removed ? "Restore model" : "Remove model" return ( - [ - styles.row, - pressed && { backgroundColor: theme.colors.surfaceRaised + "70" }, - selected && { backgroundColor: theme.colors.accent + "16" }, - ]} - onPress={() => onSelect(item)} - > - - - {item.name} - - - {item.id} - {item.reasoning && ( - - reasoning - - )} + + [ + styles.rowMain, + pressed && { backgroundColor: theme.colors.surfaceRaised + "70" }, + selected && { backgroundColor: theme.colors.accent + "16" }, + item.removed && { opacity: 0.5 }, + ]} + onPress={() => onSelect(item)} + disabled={item.removed} + > + + + {item.name} + + + + {item.providerName} • {item.id} + + {item.reasoning && ( + + reasoning + + )} + {item.removed && ( + + removed + + )} + + {selected && {"\u2713"}} + + + onFavorite(item)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={item.favorite ? "Remove favorite" : "Add favorite"} + > + + {item.favorite ? "\u2605" : "\u2606"} + + + (item.removed ? onRestore(item) : onRemove(item))} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={removeLabel} + > + + {item.removed ? "\u21BA" : "\u2212"} + + - {selected && {"\u2713"}} - + ) }) @@ -290,12 +395,20 @@ export function ModelPickerIconButton({ onPress }: { onPress: () => void }) { } const styles = StyleSheet.create({ + modalRoot: { + flex: 1, + }, backdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0,0,0,0.10)", + backgroundColor: "rgba(0,0,0,0.16)", + }, + center: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 12, }, popup: { - position: "absolute", overflow: "hidden", ...Platform.select({ ios: { @@ -318,87 +431,131 @@ const styles = StyleSheet.create({ justifyContent: "space-between", gap: 8, paddingHorizontal: 16, - paddingVertical: 12, + paddingTop: 14, + paddingBottom: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, headerText: { flex: 1, }, - currentModel: { + title: { fontSize: 19, fontWeight: "700", }, + currentModel: { + fontSize: 13, + marginTop: 3, + }, currentProvider: { fontSize: 12, marginTop: 2, }, - chevron: { - fontSize: 14, - }, - defaultRow: { - flexDirection: "row", + closeButton: { + width: 28, + height: 28, + borderRadius: 14, + justifyContent: "center", alignItems: "center", - justifyContent: "space-between", + }, + closeGlyph: { + fontSize: 13, + fontWeight: "600", + }, + searchWrap: { paddingHorizontal: 16, - paddingVertical: 11, + paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, }, - defaultLabel: { + search: { + flexDirection: "row", + alignItems: "center", + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 12, + paddingHorizontal: 10, + minHeight: 42, + }, + searchIcon: { fontSize: 15, - fontWeight: "500", + marginRight: 8, }, - list: { + searchInput: { flex: 1, + fontSize: 15, + paddingVertical: 8, }, - listContent: { - paddingBottom: 8, + clear: { + fontSize: 13, + fontWeight: "600", + paddingHorizontal: 4, }, - sectionHeader: { + controls: { flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderTopWidth: StyleSheet.hairlineWidth, + gap: 8, paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: 6, + paddingTop: 2, }, - sectionMeta: { + control: { + flex: 1, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 34, flexDirection: "row", alignItems: "center", - gap: 6, + justifyContent: "space-between", }, - sectionCount: { - fontSize: 11, + controlLabel: { + fontSize: 13, fontWeight: "500", }, - sectionChevron: { - fontSize: 10, + controlValue: { + fontSize: 12, + fontWeight: "500", }, - sectionHeaderText: { + summary: { + paddingHorizontal: 16, + paddingTop: 9, + paddingBottom: 6, + }, + summaryText: { fontSize: 11, - textTransform: "uppercase", - letterSpacing: 0.6, - fontWeight: "600", + }, + list: { + flex: 1, + }, + listContent: { + paddingBottom: 10, }, empty: { - paddingVertical: 24, + paddingVertical: 34, + paddingHorizontal: 20, alignItems: "center", }, emptyText: { fontSize: 13, + textAlign: "center", }, row: { + borderTopWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + alignItems: "stretch", + paddingHorizontal: 8, + }, + rowMain: { + flex: 1, flexDirection: "row", alignItems: "center", - paddingHorizontal: 16, + borderRadius: 10, + paddingHorizontal: 10, paddingVertical: 10, }, rowLeft: { flex: 1, - gap: 2, + gap: 3, }, modelName: { fontSize: 14, + fontWeight: "500", }, meta: { flexDirection: "row", @@ -410,7 +567,7 @@ const styles = StyleSheet.create({ }, badge: { paddingHorizontal: 5, - paddingVertical: 1, + paddingVertical: 2, borderRadius: 4, }, badgeText: { @@ -422,6 +579,25 @@ const styles = StyleSheet.create({ fontWeight: "600", marginLeft: 8, }, + rowActions: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingLeft: 6, + }, + actionButton: { + width: 30, + height: 30, + borderRadius: 15, + borderWidth: StyleSheet.hairlineWidth, + justifyContent: "center", + alignItems: "center", + }, + actionGlyph: { + fontSize: 15, + fontWeight: "600", + marginTop: -1, + }, iconButton: { width: 44, height: 44, diff --git a/packages/mobile/src/store/settings.ts b/packages/mobile/src/store/settings.ts index 8f60795c2921..469762fdc3a6 100644 --- a/packages/mobile/src/store/settings.ts +++ b/packages/mobile/src/store/settings.ts @@ -5,34 +5,106 @@ import { client } from "../api/client" type ModelKey = { providerID: string; modelID: string } type Appearance = "light" | "dark" | "system" +type ModelMap = Record type SettingsState = { config: Config | null providerData: ProviderListResponse | null providerAuth: ProviderAuthResponse | null model: ModelKey | null + favorites: ModelMap + removed: ModelMap appearance: Appearance fetchConfig: () => Promise fetchProviders: () => Promise fetchProviderAuth: () => Promise setModel: (m: ModelKey | null) => void + toggleFavorite: (m: ModelKey) => void + removeModel: (m: ModelKey) => void + restoreModel: (m: ModelKey) => void setAppearance: (a: Appearance) => void restoreAppearance: () => Promise } const APPEARANCE_KEY = "appearance" const MODEL_KEY = "selected_model" +const FAVORITE_MODELS_KEY = "favorite_models" +const REMOVED_MODELS_KEY = "removed_models" const DEBUG_PROVIDER_FETCH = __DEV__ && (globalThis as { __OPENCODE_MOBILE_PROVIDER_DEBUG__?: boolean }).__OPENCODE_MOBILE_PROVIDER_DEBUG__ === true +export function modelKey(model: ModelKey) { + return `${model.providerID}:${model.modelID}` +} + +function parse(raw: string | null): T | null { + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +function decode(raw: string | null) { + const list = parse>(raw) + if (!Array.isArray(list)) return {} + return Object.fromEntries(list.filter((item) => typeof item === "string").map((item) => [item, true])) +} + +function encode(map: ModelMap) { + const list = Object.keys(map) + if (!list.length) return null + return JSON.stringify(list) +} + +function saveMap(storageKey: string, map: ModelMap) { + const raw = encode(map) + if (!raw) { + AsyncStorage.removeItem(storageKey) + return + } + AsyncStorage.setItem(storageKey, raw) +} + +function knownModels(providerData: ProviderListResponse) { + const connected = new Set(providerData.connected) + const models = new Set() + + for (const provider of providerData.all) { + if (!connected.has(provider.id)) continue + for (const [modelID, info] of Object.entries(provider.models)) { + if (info.status === "deprecated") continue + models.add(`${provider.id}:${info.id || modelID}`) + } + } + + return models +} + +function keepKnown(map: ModelMap, known: Set) { + const keys = Object.keys(map).filter((key) => known.has(key)) + return Object.fromEntries(keys.map((key) => [key, true])) +} + +function hasModel(providerData: ProviderListResponse, providerID: string, modelID: string) { + const provider = providerData.all.find((item) => item.id === providerID) + if (!provider) return false + return Object.entries(provider.models).some(([key, info]) => (info.id || key) === modelID) +} + +function getModel(providerData: ProviderListResponse, providerID: string, modelID: string) { + const provider = providerData.all.find((item) => item.id === providerID) + if (!provider) return null + const entry = Object.entries(provider.models).find(([key, info]) => (info.id || key) === modelID) + return entry?.[1] || null +} + export function modelName(state: SettingsState): string { if (!state.model || !state.providerData) return "Default" - for (const provider of state.providerData.all) { - if (provider.id !== state.model.providerID) continue - const info = provider.models[state.model.modelID] - if (info) return info.name || state.model.modelID - } + const info = getModel(state.providerData, state.model.providerID, state.model.modelID) + if (info) return info.name || state.model.modelID return state.model.modelID } @@ -41,6 +113,8 @@ export const useSettings = createStore((set, get) => ({ providerData: null, providerAuth: null, model: null, + favorites: {}, + removed: {}, appearance: "system", fetchConfig: async () => { @@ -67,18 +141,27 @@ export const useSettings = createStore((set, get) => ({ } } const selected = get().model + const state = get() const providerData = result.data + const known = knownModels(providerData) + const favorites = keepKnown(state.favorites, known) + const removed = keepKnown(state.removed, known) let nextModel = selected if (selected) { - const provider = providerData.all.find((item) => item.id === selected.providerID) const connected = providerData.connected.includes(selected.providerID) - const exists = !!provider?.models?.[selected.modelID] + const exists = hasModel(providerData, selected.providerID, selected.modelID) if (!connected || !exists) { nextModel = null AsyncStorage.removeItem(MODEL_KEY) } + if (removed[modelKey(selected)]) { + nextModel = null + AsyncStorage.removeItem(MODEL_KEY) + } } - set({ providerData, model: nextModel }) + set({ providerData, model: nextModel, favorites, removed }) + saveMap(FAVORITE_MODELS_KEY, favorites) + saveMap(REMOVED_MODELS_KEY, removed) } else if (DEBUG_PROVIDER_FETCH) { console.warn("[providers] no data in response", result) } @@ -107,26 +190,87 @@ export const useSettings = createStore((set, get) => ({ } }, + toggleFavorite: (model) => { + const key = modelKey(model) + const state = get() + + if (state.favorites[key]) { + const favorites = { ...state.favorites } + delete favorites[key] + set({ favorites }) + saveMap(FAVORITE_MODELS_KEY, favorites) + return + } + + const favorites = { ...state.favorites, [key]: true } + if (state.removed[key]) { + const removed = { ...state.removed } + delete removed[key] + set({ favorites, removed }) + saveMap(FAVORITE_MODELS_KEY, favorites) + saveMap(REMOVED_MODELS_KEY, removed) + return + } + + set({ favorites }) + saveMap(FAVORITE_MODELS_KEY, favorites) + }, + + removeModel: (model) => { + const key = modelKey(model) + const state = get() + if (state.removed[key]) return + + const removed = { ...state.removed, [key]: true } + const updates: Partial = { removed } + + if (state.favorites[key]) { + const favorites = { ...state.favorites } + delete favorites[key] + updates.favorites = favorites + saveMap(FAVORITE_MODELS_KEY, favorites) + } + + if (state.model && modelKey(state.model) === key) { + updates.model = null + AsyncStorage.removeItem(MODEL_KEY) + } + + set(updates) + saveMap(REMOVED_MODELS_KEY, removed) + }, + + restoreModel: (model) => { + const key = modelKey(model) + const state = get() + if (!state.removed[key]) return + const removed = { ...state.removed } + delete removed[key] + set({ removed }) + saveMap(REMOVED_MODELS_KEY, removed) + }, + setAppearance: (appearance) => { set({ appearance }) AsyncStorage.setItem(APPEARANCE_KEY, appearance) }, restoreAppearance: async () => { - const [stored, modelRaw] = await Promise.all([ + const [stored, modelRaw, favoriteRaw, removedRaw] = await Promise.all([ AsyncStorage.getItem(APPEARANCE_KEY), AsyncStorage.getItem(MODEL_KEY), + AsyncStorage.getItem(FAVORITE_MODELS_KEY), + AsyncStorage.getItem(REMOVED_MODELS_KEY), ]) const updates: Partial = {} if (stored === "light" || stored === "dark" || stored === "system") { updates.appearance = stored } + updates.favorites = decode(favoriteRaw) + updates.removed = decode(removedRaw) if (modelRaw) { - try { - updates.model = JSON.parse(modelRaw) - } catch { - // ignore - } + const model = parse(modelRaw) + if (model) updates.model = model } set(updates) },