diff --git a/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx
new file mode 100644
index 0000000000..5473a1fdee
--- /dev/null
+++ b/apps/desktop/src/app/shell/model-catalog-menu.search-fold.test.tsx
@@ -0,0 +1,108 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'
+import { $localModelsEnabled } from '@/store/local-models-flag'
+import { $localRuntimeJobs } from '@/store/local-runtime-jobs'
+import { $visibleModels } from '@/store/model-visibility'
+
+import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
+
+// Radix calls these on open; jsdom doesn't implement them.
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn()
+ Element.prototype.hasPointerCapture = vi.fn(() => false)
+ Element.prototype.releasePointerCapture = vi.fn()
+})
+
+const getGlobalModelOptions = vi.fn()
+
+vi.mock('@/hermes', () => ({
+ getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
+ getLocalModelsJobs: vi.fn(async () => ({ jobs: [] })),
+ getLocalModelsStatus: vi.fn().mockResolvedValue({ loading: {} }),
+ setApiRequestProfile: vi.fn()
+}))
+
+beforeEach(() => {
+ $visibleModels.set(null)
+ $localRuntimeJobs.set([])
+ $localModelsEnabled.set(false)
+ getGlobalModelOptions.mockResolvedValue({
+ providers: [{ models: ['qwen3.8-flash', 'gpt-5.1'], name: 'OpenRouter', slug: 'openrouter' }]
+ })
+})
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+function renderMenu() {
+ const controller: ModelMenuController = {
+ applyPreset: vi.fn(),
+ current: { effort: '', fast: false, model: '', provider: '' },
+ presetFor: () => ({}),
+ select: vi.fn(async () => true),
+ setOptions: vi.fn()
+ }
+
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+ render(
+
+
+
+
+
+
+
+ )
+}
+
+
+
+
+// The row label span (class "truncate") carries the full label text plus the
+// meta suffix ("High"/"Min"), so match by prefix. Works for both the plain
+// and the -split rendering.
+function rowTruncateSpan(prefix: string) {
+ return screen.queryByText((_, element) =>
+ Boolean(element?.classList.contains('truncate') && (element?.textContent ?? '').startsWith(prefix))
+ )
+}
+
+// The search filter and the highlight must agree: whatever a query reveals,
+// the row's label marks the query (separator-folded equivalence). Pre-fix,
+// hyphen queries revealed rows with zero marks, and space queries matched
+// only via the display segment - both polarity failures covered here.
+describe('model catalog search: fold between filter and highlight', () => {
+ it('HYPHEN query both filters and highlights the SPACED label (the reported defect - fails pre-fix)', async () => {
+ renderMenu()
+ await screen.findByText(/Qwen3\.8 Flash/i)
+
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search models' }), { target: { value: 'qwen3.8-flash' } })
+
+ await vi.waitFor(() => {
+ // Whole label matches contiguously -> renders as a single .
+ expect(screen.getByText('Qwen3.8 Flash', { selector: 'mark' })).toBeDefined()
+ })
+ })
+
+ it('SPACE query finds the HYPHENATED id (superset guarantee) and highlights', async () => {
+ renderMenu()
+ await screen.findByText(/Qwen3\.8 Flash/i)
+
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search models' }), { target: { value: 'qwen3 8' } })
+
+ await vi.waitFor(() => {
+ // Partial label match -> Qwen3.8 + ' Flash'.
+ expect(screen.getByText('Qwen3.8', { selector: 'mark' })).toBeDefined()
+ // The row label (before the meta suffix) is intact.
+ expect(rowTruncateSpan('Qwen3.8 Flash')).toBeDefined()
+ // The fold must not over-match: a qwen query still hides GPT rows.
+ expect(rowTruncateSpan('GPT-5.1')).toBeNull()
+ })
+ })
+})
diff --git a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx
index ae42a4a36c..6e8e1494bf 100644
--- a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx
+++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx
@@ -108,7 +108,16 @@ describe('the catalog owns model curation', () => {
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
await vi.waitFor(() => {
- expect(screen.queryByText(/Gemini 3\.1 Pro/i)).not.toBeNull()
+ // The fold makes this id-style query highlight the spaced label: the
+ // row renders as Gemini 3.1 + ' Pro'.
+ expect(screen.getByText('Gemini 3.1', { selector: 'mark' })).toBeDefined()
+ // Display name is "Gemini 3.1 pro" (no title-case for gemini ids); the
+ // row label span carries it (plus the effort meta suffix).
+ expect(
+ screen.getByText((_, element) =>
+ Boolean(element?.classList.contains('truncate') && (element?.textContent ?? '').startsWith('Gemini 3.1 pro'))
+ )
+ ).toBeDefined()
})
})
diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx
index 3cde3cd229..78c563ddea 100644
--- a/apps/desktop/src/app/shell/model-catalog-menu.tsx
+++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx
@@ -24,7 +24,7 @@ import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
-import { normalize } from '@/lib/text'
+import { foldIncludes, normalize } from '@/lib/text'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $localModelsEnabled } from '@/store/local-models-flag'
@@ -265,7 +265,7 @@ export function ModelCatalogMenu({
// In-flight downloads render inside the Local provider group when it
// exists, else as their own trailing 'Local' group (first download —
// nothing staged yet, so the catalog has no local provider row).
- const shownDownloads = q ? downloads.filter(job => (job.target || '').toLowerCase().includes(q)) : downloads
+ const shownDownloads = q ? downloads.filter(job => foldIncludes(job.target || '', q)) : downloads
const hasLocalGroup = pickerProviders.some(provider => provider.slug === LOCAL_PROVIDER_SLUG)
// Resolve visibility HERE, against the catalog we actually fetched: an empty
@@ -285,7 +285,7 @@ export function ModelCatalogMenu({
// sitting under zero model matches would otherwise become the "first match"
// Enter commits.
const shownMoaPresets = useMemo(
- () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
+ () => (q ? moaPresets.filter(preset => foldIncludes(`moa ${preset}`, q)) : moaPresets),
[moaPresets, q]
)
@@ -729,9 +729,10 @@ function groupModels(
}
const matches = (family: ModelFamily) =>
- `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
- .toLowerCase()
- .includes(q)
+ foldIncludes(
+ `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`,
+ q
+ )
let shown: Set
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx
index f2d7283f17..e36bc27def 100644
--- a/apps/desktop/src/components/model-picker.tsx
+++ b/apps/desktop/src/components/model-picker.tsx
@@ -6,7 +6,7 @@ import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { modelSearchText } from '@/lib/model-search-text'
import { currentPickerSelection } from '@/lib/model-status-label'
-import { normalize } from '@/lib/text'
+import { foldIncludes, normalize } from '@/lib/text'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $localModelsEnabled } from '@/store/local-models-flag'
import { $localRuntimeJobs, runningModelDownloads, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
@@ -266,9 +266,9 @@ function ModelResults({
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
- modelSearchText(model).toLowerCase().includes(q) ||
- provider.name.toLowerCase().includes(q) ||
- provider.slug.toLowerCase().includes(q)
+ foldIncludes(modelSearchText(model), q) ||
+ foldIncludes(provider.name, q) ||
+ foldIncludes(provider.slug, q)
// Only configured providers (those with curated models) are selectable
// here. Switching to a NOT-yet-configured provider goes through the
diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx
index 5fe32d929a..626d5c59c9 100644
--- a/apps/desktop/src/components/model-visibility-dialog.tsx
+++ b/apps/desktop/src/components/model-visibility-dialog.tsx
@@ -14,7 +14,7 @@ import { useI18n } from '@/i18n'
import { Search } from '@/lib/icons'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
-import { normalize } from '@/lib/text'
+import { foldIncludes, normalize } from '@/lib/text'
import {
$visibleModels,
collapseModelFamilies,
@@ -76,7 +76,7 @@ export function ModelVisibilityDialog({
const q = normalize(search)
const matches = (provider: ModelOptionProvider, model: string) =>
- !q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q)
+ !q || foldIncludes(`${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`, q)
return (