From 16ec76735322e089a08cd3275e9c7ffc6362a382 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 20 Feb 2026 08:23:07 +0530 Subject: [PATCH 1/4] fix: image model filters, search UX, and recommended toggle clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix backend filter (CPU/NPU) being ignored when recommended star is on; now always applies regardless of showRecommendedOnly state - Make image model search sticky (only search row, banner/filters scroll) - Move device RAM banner and filter bar into scrollable content area so the list gets full screen real estate - Replace Feather outline star with MaterialIcons filled/outline star so the active state is visually obvious - Allow text model search to trigger with empty query when type/org/size filters are active, enabling filter-driven browsing of HuggingFace - Pass type filter to HF API as pipeline_tag (vision → image-text-to-text) for server-side filtering instead of pure client-side - Remove recommended-first sort when star is off; show alphabetically so toggling the star has a clear visible effect - Add first-time dismissable hint below search explaining the star toggle Co-Authored-By: Claude Sonnet 4.6 --- src/screens/ModelsScreen.tsx | 95 +++++++++++++++++++++++++++--------- src/services/huggingface.ts | 7 ++- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/src/screens/ModelsScreen.tsx b/src/screens/ModelsScreen.tsx index 9743a1e51..d38064361 100644 --- a/src/screens/ModelsScreen.tsx +++ b/src/screens/ModelsScreen.tsx @@ -16,6 +16,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import Icon from 'react-native-vector-icons/Feather'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import RNFS from 'react-native-fs'; import { unzip } from 'react-native-zip-archive'; import { Card, ModelCard, Button } from '../components'; @@ -186,6 +187,7 @@ export const ModelsScreen: React.FC = () => { const [imageRec, setImageRec] = useState(null); const [userChangedBackendFilter, setUserChangedBackendFilter] = useState(false); const [showRecommendedOnly, setShowRecommendedOnly] = useState(true); + const [showRecHint, setShowRecHint] = useState(true); // Fetched details for recommended models (real downloads, likes, files from HF API) const [recommendedModelDetails, setRecommendedModelDetails] = useState>({}); @@ -341,17 +343,35 @@ export const ModelsScreen: React.FC = () => { const handleSearch = async () => { Keyboard.dismiss(); setFilterState(prev => ({ ...prev, expandedDimension: null })); - if (!searchQuery.trim()) { + + const hasQuery = searchQuery.trim().length > 0; + const hasTypeFilter = filterState.type !== 'all'; + const hasOrgFilter = filterState.orgs.length > 0; + const hasSizeFilter = filterState.size !== 'all'; + + // No query and no meaningful filters → go back to recommended view + if (!hasQuery && !hasTypeFilter && !hasOrgFilter && !hasSizeFilter) { setHasSearched(false); setSearchResults([]); return; } + // Map type filter to HF pipeline_tag for server-side filtering + let pipelineTag: string | undefined; + let effectiveQuery = searchQuery.trim(); + if (filterState.type === 'vision') { + pipelineTag = 'image-text-to-text'; + } else if (filterState.type === 'code' && !effectiveQuery) { + // No specific HF pipeline tag for code — fall back to a keyword search + effectiveQuery = 'coder'; + } + setIsLoading(true); setHasSearched(true); try { - const results = await huggingFaceService.searchModels(searchQuery, { + const results = await huggingFaceService.searchModels(effectiveQuery, { limit: 30, + pipelineTag, }); setSearchResults(results); } catch (_error) { @@ -1296,8 +1316,8 @@ export const ModelsScreen: React.FC = () => { const query = imageSearchQuery.toLowerCase().trim(); const filtered = availableHFModels.filter((m) => { if (showRecommendedOnly && imageRec && !isRecommendedModel(m)) return false; - // Skip backend filter when recommended is active (recommendation already handles backend) - if (!showRecommendedOnly && backendFilter !== 'all' && m.backend !== backendFilter) return false; + // Always apply backend filter so user-selected CPU/NPU overrides recommendations + if (backendFilter !== 'all' && m.backend !== backendFilter) return false; if (styleFilter !== 'all' && guessStyle(m.name) !== styleFilter) return false; // SD version filter (iOS Core ML) if (sdVersionFilter !== 'all') { @@ -1310,13 +1330,9 @@ export const ModelsScreen: React.FC = () => { if (query && !m.displayName.toLowerCase().includes(query) && !m.name.toLowerCase().includes(query)) return false; return true; }); - // Sort recommended models first when showing all - if (!showRecommendedOnly && imageRec) { - filtered.sort((a, b) => { - const aRec = isRecommendedModel(a) ? 0 : 1; - const bRec = isRecommendedModel(b) ? 0 : 1; - return aRec - bRec; - }); + // When showing all models, sort alphabetically so the toggle has a clear visible effect + if (!showRecommendedOnly) { + filtered.sort((a, b) => a.displayName.localeCompare(b.displayName)); } return filtered; }, [availableHFModels, backendFilter, styleFilter, sdVersionFilter, downloadedImageModels, imageSearchQuery, imageRec, isRecommendedModel, showRecommendedOnly]); @@ -1491,8 +1507,8 @@ export const ModelsScreen: React.FC = () => { ); } - // Render image models section - const renderImageModelsSection = () => ( + // Render sticky header for image models (search, device banner, filter bar) + const renderImageModelsHeader = () => ( {/* Search */} @@ -1507,6 +1523,7 @@ export const ModelsScreen: React.FC = () => { { + setShowRecHint(false); setShowRecommendedOnly(v => { // When toggling off recommended, reset backend filter so all models show if (v) setBackendFilter('all'); @@ -1515,7 +1532,7 @@ export const ModelsScreen: React.FC = () => { }} hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }} > - + { + + ); + + // Render scrollable list of image models (includes banner, filters, and cards) + const renderImageModelsList = () => ( + + {/* First-time hint for the star/recommended toggle */} + {showRecHint && showRecommendedOnly && ( + setShowRecHint(false)} activeOpacity={0.7}> + + Showing recommended models only. Tap to see all. + + )} + {/* Device recommendation */} @@ -1652,7 +1683,6 @@ export const ModelsScreen: React.FC = () => { )} } - {/* Loading / Error / List */} {hfModelsLoading && ( @@ -2058,13 +2088,14 @@ export const ModelsScreen: React.FC = () => { )} {/* Image Models Tab */} - { - activeTab === 'image' && ( - - {renderImageModelsSection()} + {activeTab === 'image' && ( + + {renderImageModelsHeader()} + + {renderImageModelsList()} - ) - } + + )} setAlertState(hideAlert())} /> ); @@ -2184,6 +2215,23 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ recToggleActive: { backgroundColor: colors.primary + '15', }, + recHint: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 5, + paddingHorizontal: 10, + paddingVertical: 6, + marginBottom: 8, + backgroundColor: colors.primary + '10', + borderRadius: 8, + borderWidth: 1, + borderColor: colors.primary + '30', + }, + recHintText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + flex: 1, + }, filterToggle: { padding: 12, borderRadius: 12, @@ -2450,9 +2498,12 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ }, // Image models section styles imageModelsSection: { - marginBottom: 24, paddingHorizontal: 16, }, + imageModelsList: { + paddingHorizontal: 16, + marginBottom: 24, + }, imageSectionTitle: { ...TYPOGRAPHY.h1, color: colors.text, diff --git a/src/services/huggingface.ts b/src/services/huggingface.ts index 42812c821..53e13a321 100644 --- a/src/services/huggingface.ts +++ b/src/services/huggingface.ts @@ -11,9 +11,10 @@ class HuggingFaceService { limit?: number; sort?: string; direction?: string; + pipelineTag?: string; } = {} ): Promise { - const { limit = 30, sort = 'downloads', direction = '-1' } = options; + const { limit = 30, sort = 'downloads', direction = '-1', pipelineTag } = options; try { const params = new URLSearchParams({ @@ -27,6 +28,10 @@ class HuggingFaceService { params.append('search', query); } + if (pipelineTag) { + params.append('pipeline_tag', pipelineTag); + } + const response = await fetch( `${this.apiUrl}/models?${params.toString()}`, { From 84db215bafd98c162a23fff5af595601c25df8a3 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 20 Feb 2026 08:26:59 +0530 Subject: [PATCH 2/4] chore: extract pipeline tag and code fallback into named constants Addresses review feedback: move magic strings 'image-text-to-text' and 'coder' into VISION_PIPELINE_TAG and CODE_FALLBACK_QUERY constants at module level for easier maintenance. Co-Authored-By: Claude Sonnet 4.6 --- src/screens/ModelsScreen.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/screens/ModelsScreen.tsx b/src/screens/ModelsScreen.tsx index d38064361..96fc19073 100644 --- a/src/screens/ModelsScreen.tsx +++ b/src/screens/ModelsScreen.tsx @@ -94,6 +94,9 @@ const QUANT_OPTIONS = [ type FilterDimension = 'org' | 'type' | 'source' | 'size' | 'quant' | null; type ImageFilterDimension = 'backend' | 'style' | 'sdVersion' | null; +const VISION_PIPELINE_TAG = 'image-text-to-text'; +const CODE_FALLBACK_QUERY = 'coder'; + const STYLE_OPTIONS = [ { key: 'all', label: 'All Styles' }, { key: 'photorealistic', label: 'Realistic' }, @@ -360,10 +363,10 @@ export const ModelsScreen: React.FC = () => { let pipelineTag: string | undefined; let effectiveQuery = searchQuery.trim(); if (filterState.type === 'vision') { - pipelineTag = 'image-text-to-text'; + pipelineTag = VISION_PIPELINE_TAG; } else if (filterState.type === 'code' && !effectiveQuery) { // No specific HF pipeline tag for code — fall back to a keyword search - effectiveQuery = 'coder'; + effectiveQuery = CODE_FALLBACK_QUERY; } setIsLoading(true); From 485ef90df7552dcd21cf8592347caf6cfecd08ac Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 20 Feb 2026 08:39:47 +0530 Subject: [PATCH 3/4] test: add tests for image model filter fixes and search UX changes - huggingface.test.ts: verify pipeline_tag is appended to URL when pipelineTag option is provided, not appended when omitted, and works correctly combined with a search query - ModelsScreen.test.tsx: verify showRecommendedOnly filters out non- recommended (qnn) models when recommendedBackend is mnn; verify first-time rec hint is visible on image tab open and dismissed when the toggle is pressed; verify handleSearch triggers HF search with empty query when vision/code type filters are active; verify search is NOT triggered when query is empty and no filters are set Co-Authored-By: Claude Sonnet 4.6 --- __tests__/rntl/screens/ModelsScreen.test.tsx | 164 +++++++++++++++++++ __tests__/unit/services/huggingface.test.ts | 40 +++++ src/screens/ModelsScreen.tsx | 1 + 3 files changed, 205 insertions(+) diff --git a/__tests__/rntl/screens/ModelsScreen.test.tsx b/__tests__/rntl/screens/ModelsScreen.test.tsx index 0f9f589e3..9c9bca7bd 100644 --- a/__tests__/rntl/screens/ModelsScreen.test.tsx +++ b/__tests__/rntl/screens/ModelsScreen.test.tsx @@ -2575,6 +2575,170 @@ describe('ModelsScreen', () => { }); }); + // ============================================================================ + // recommended toggle and backend filter behaviour + // ============================================================================ + describe('image model recommended toggle and backend filter', () => { + const mnnModel = { + id: 'cpu-model', + name: 'cpu-model', + displayName: 'CPU Model', + backend: 'mnn' as const, + fileName: 'cpu.zip', + downloadUrl: 'https://example.com/cpu.zip', + size: 500000000, + repo: 'test/cpu-model', + }; + const qnnModel = { + id: 'npu-model', + name: 'npu-model', + displayName: 'NPU Model', + backend: 'qnn' as const, + fileName: 'npu.zip', + downloadUrl: 'https://example.com/npu.zip', + size: 500000000, + repo: 'test/npu-model', + }; + + it('hides qnn model when showRecommendedOnly is on and recommendedBackend is mnn', async () => { + mockFetchAvailableModels.mockResolvedValue([mnnModel, qnnModel]); + + const { queryByText, getByText } = renderModelsScreen(); + + await act(async () => { + fireEvent.press(getByText('Image Models')); + }); + + // Allow async state (imageRec + models) to fully settle + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + // CPU Model (mnn) matches recommendedBackend='mnn' → visible + // NPU Model (qnn) does not match → filtered out by showRecommendedOnly + expect(queryByText('NPU Model')).toBeNull(); + }); + + it('dismisses first-time hint when rec-toggle is pressed', async () => { + mockFetchAvailableModels.mockResolvedValue([mnnModel]); + + const { getByText, getByTestId, queryByText } = renderModelsScreen(); + + await act(async () => { + fireEvent.press(getByText('Image Models')); + }); + + await waitFor(() => { + expect(getByText(/RAM/)).toBeTruthy(); + }); + + // Hint should be visible on first open (showRecHint=true, showRecommendedOnly=true) + expect(queryByText(/Showing recommended models only/)).toBeTruthy(); + + // Pressing the toggle dismisses the hint and turns off recommended mode + await act(async () => { + fireEvent.press(getByTestId('rec-toggle')); + }); + + await waitFor(() => { + expect(queryByText(/Showing recommended models only/)).toBeNull(); + }); + }); + }); + + // ============================================================================ + // handleSearch with filters + // ============================================================================ + describe('handleSearch with active filters', () => { + it('triggers HuggingFace search when vision type filter is set and query is empty', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + // Open filter bar + await act(async () => { + fireEvent.press(getByTestId('text-filter-toggle')); + }); + + // Select Vision type filter + await act(async () => { + fireEvent.press(getByText(/^Type/)); + }); + + await act(async () => { + fireEvent.press(getByText('Vision')); + }); + + // Hit search with empty query but vision filter active + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + await waitFor(() => { + expect(mockSearchModels).toHaveBeenCalledWith( + '', // empty query + expect.objectContaining({ pipelineTag: 'image-text-to-text' }), + ); + }); + }); + + it('does not trigger HuggingFace search when query is empty and no filters are active', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + mockSearchModels.mockClear(); + + // Hit search with empty query and no filters + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + expect(mockSearchModels).not.toHaveBeenCalled(); + // Should still show recommended section + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + }); + + it('triggers HuggingFace search with "coder" keyword when code filter is set and query is empty', async () => { + const { getByText, getByTestId } = renderModelsScreen(); + + await waitFor(() => { + expect(getByText(/Recommended for your device/)).toBeTruthy(); + }); + + // Open filter bar + await act(async () => { + fireEvent.press(getByTestId('text-filter-toggle')); + }); + + // Select Code type filter + await act(async () => { + fireEvent.press(getByText(/^Type/)); + }); + + await act(async () => { + fireEvent.press(getByText('Code')); + }); + + await act(async () => { + fireEvent.press(getByTestId('search-button')); + }); + + await waitFor(() => { + expect(mockSearchModels).toHaveBeenCalledWith( + 'coder', + expect.objectContaining({ limit: 30 }), + ); + }); + }); + }); + // ============================================================================ // formatNumber utility // ============================================================================ diff --git a/__tests__/unit/services/huggingface.test.ts b/__tests__/unit/services/huggingface.test.ts index 833f61749..d4f062240 100644 --- a/__tests__/unit/services/huggingface.test.ts +++ b/__tests__/unit/services/huggingface.test.ts @@ -414,6 +414,46 @@ describe('HuggingFaceService', () => { const url = mockFetch.mock.calls[0][0]; expect(url).toContain('limit=10'); }); + + it('appends pipeline_tag when pipelineTag option is provided', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('', { pipelineTag: 'image-text-to-text' }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('pipeline_tag=image-text-to-text'); + }); + + it('does not append pipeline_tag when option is not provided', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('test'); + + const url = mockFetch.mock.calls[0][0]; + expect(url).not.toContain('pipeline_tag'); + }); + + it('combines query and pipeline_tag in the same request', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + (global as any).fetch = mockFetch; + + await huggingFaceService.searchModels('qwen', { pipelineTag: 'image-text-to-text' }); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('search=qwen'); + expect(url).toContain('pipeline_tag=image-text-to-text'); + }); }); // ============================================================================ diff --git a/src/screens/ModelsScreen.tsx b/src/screens/ModelsScreen.tsx index 96fc19073..09cd1d2b9 100644 --- a/src/screens/ModelsScreen.tsx +++ b/src/screens/ModelsScreen.tsx @@ -1534,6 +1534,7 @@ export const ModelsScreen: React.FC = () => { }); }} hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }} + testID="rec-toggle" > From f8fdc4d007a9d5b675c8253ef7bcf6326b152501 Mon Sep 17 00:00:00 2001 From: Mac Date: Fri, 20 Feb 2026 08:42:53 +0530 Subject: [PATCH 4/4] refactor(test): mirror VISION_PIPELINE_TAG and CODE_FALLBACK_QUERY constants in test file Addresses reviewer comment: instead of hardcoding 'image-text-to-text' and 'coder' as magic strings in assertions, declare mirrored constants at the top of the test file so changes to the source constants are immediately visible as test failures. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/rntl/screens/ModelsScreen.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/__tests__/rntl/screens/ModelsScreen.test.tsx b/__tests__/rntl/screens/ModelsScreen.test.tsx index 9c9bca7bd..59d9b2a46 100644 --- a/__tests__/rntl/screens/ModelsScreen.test.tsx +++ b/__tests__/rntl/screens/ModelsScreen.test.tsx @@ -14,6 +14,10 @@ import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; import { useAppStore } from '../../../src/stores/appStore'; import { resetStores } from '../../utils/testHelpers'; + +// Mirror constants from ModelsScreen so test assertions stay in sync with the source +const VISION_PIPELINE_TAG = 'image-text-to-text'; +const CODE_FALLBACK_QUERY = 'coder'; import { createDownloadedModel, createONNXImageModel, @@ -2679,7 +2683,7 @@ describe('ModelsScreen', () => { await waitFor(() => { expect(mockSearchModels).toHaveBeenCalledWith( '', // empty query - expect.objectContaining({ pipelineTag: 'image-text-to-text' }), + expect.objectContaining({ pipelineTag: VISION_PIPELINE_TAG }), ); }); }); @@ -2732,7 +2736,7 @@ describe('ModelsScreen', () => { await waitFor(() => { expect(mockSearchModels).toHaveBeenCalledWith( - 'coder', + CODE_FALLBACK_QUERY, expect.objectContaining({ limit: 30 }), ); });