diff --git a/apps/desktop/src/app/chat/sidebar/session-row-details.ts b/apps/desktop/src/app/chat/sidebar/session-row-details.ts index 06cee514d6..3c3f234d55 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row-details.ts +++ b/apps/desktop/src/app/chat/sidebar/session-row-details.ts @@ -17,6 +17,16 @@ const oneLine = (value: null | string) => value?.replace(/\s+/g, ' ').trim() || export const sessionRowEstimate = (density: SessionListDensity) => ({ compact: 28, comfortable: 45, detailed: 63 })[density] +/** Virtual-list placement estimate for the Inbox-style card. A full card + * stacks four text lines (header, title, preview, model/size) where the + * tallest inline density stacks three, plus the card's own padding — and a + * title that wraps to two lines on a narrow sidebar adds one more title + * line (#88473). Deliberately at or ABOVE that worst case: an oversized + * estimate paints a brief gap that self-measurement closes, while an + * undersized one paints rows over their neighbours (and the divider below) + * on a cold start, before any measurement can correct it. */ +export const SESSION_CARD_ROW_ESTIMATE_PX = 96 + export function sessionRowDetails(session: SessionInfo, fmt: SessionRowFormatters): SessionRowDetails { const preview = oneLine(session.preview) const hasOwnTitle = Boolean(session.title?.trim()) diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx new file mode 100644 index 0000000000..8cc4fc0cc1 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx @@ -0,0 +1,124 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/hermes' +import type { SidebarListRow } from '@/lib/session-date-groups' +import { $sessionListDensity } from '@/store/session-list-density' + +import { SESSION_CARD_ROW_ESTIMATE_PX, sessionRowEstimate } from './session-row-details' +import { VirtualSessionList } from './virtual-session-list' + +// The virtualizer is mocked with a STABLE instance (the real hook returns +// one), so the component's measure() effect fires exactly when its deps +// change — which is the behavior under test: `card` must invalidate cached +// measurements, or toggling Inbox style leaves the previous mode's row +// heights in place (#88473). +const measureSpy = vi.fn() +let estimateSize: (index: number) => number = () => 0 + +const stableVirtualizer = { + measure: (...args: []) => measureSpy(...args), + getVirtualItems: () => [], + getTotalSize: () => 0, + measureElement: vi.fn() +} + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: { estimateSize: (index: number) => number }) => { + estimateSize = options.estimateSize + + return stableVirtualizer + } +})) + +vi.mock('./chrome', () => ({ SidebarDateDivider: () => null })) +vi.mock('./session-row', () => ({ SidebarSessionRow: () => null })) + +vi.mock('@/i18n', () => ({ + useI18n: () => ({ t: { sidebar: { dateDivider: {} } } }) +})) + +const session = (id: string) => + ({ archived: false, id, last_active: 0, profile: 'default', started_at: 0 }) as unknown as SessionInfo + +const rows: SidebarListRow[] = [ + { key: 'today', kind: 'divider', label: 'Today' }, + { entry: { session: session('s1') }, kind: 'session' }, + { entry: { session: session('s2') }, kind: 'session' } +] + +const defaultProps = { + activeSessionId: null, + onDeleteSession: () => {}, + onResumeSession: () => {}, + onArchiveSession: () => {}, + onTogglePin: () => {}, + onToggleUnread: () => {}, + pinned: false, + rows, + sortable: false +} + +function renderList(props: Partial[0]> = {}) { + return render() +} + +describe('VirtualSessionList row measurement', () => { + beforeEach(() => { + measureSpy.mockClear() + $sessionListDensity.set('compact') + }) + + afterEach(() => { + cleanup() + }) + + it('re-measures when the density changes', () => { + const { rerender } = renderList() + + const afterMount = measureSpy.mock.calls.length + + $sessionListDensity.set('detailed') + rerender() + + expect(measureSpy.mock.calls.length).toBeGreaterThan(afterMount) + }) + + it('re-measures when Inbox card mode toggles — stale compact measurements must not survive the switch (#88473)', () => { + const { rerender } = renderList() + + const afterMount = measureSpy.mock.calls.length + + rerender() + + expect(measureSpy.mock.calls.length).toBeGreaterThan(afterMount) + }) + + it('routes the estimate by row kind and mode', () => { + const { rerender } = renderList() + + // Dividers keep their own fixed estimate in every mode. + expect(estimateSize(0)).toBe(28) + expect(estimateSize(1)).toBe(sessionRowEstimate('compact')) + + rerender() + + expect(estimateSize(0)).toBe(28) + expect(estimateSize(1)).toBe(SESSION_CARD_ROW_ESTIMATE_PX) + }) + + it('estimates a card at or above the tallest four-line card stack (#88473)', () => { + // A full Inbox card renders four text lines (header, title, preview, + // model/size) where the tallest inline density renders three — plus the + // card's own padding, and one more title line when the title wraps on a + // narrow sidebar. The estimate must cover that worst case: undersized + // estimates paint rows over their neighbours on cold start, before + // self-measurement can correct them. + const onePreviewLine = 13.5 + const oneTitleLine = 17.6 + + expect(SESSION_CARD_ROW_ESTIMATE_PX).toBeGreaterThanOrEqual( + sessionRowEstimate('detailed') + onePreviewLine + oneTitleLine + ) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index 6fa8a7ed1f..5406397453 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -15,7 +15,7 @@ import { $sessionListDensity } from '@/store/session-list-density' import { SidebarDateDivider } from './chrome' import { SidebarSessionRow } from './session-row' -import { sessionRowEstimate } from './session-row-details' +import { SESSION_CARD_ROW_ESTIMATE_PX, sessionRowEstimate } from './session-row-details' interface SessionRowCommonProps { branchStem?: string @@ -60,8 +60,8 @@ export interface VirtualSessionListProps { // Matches the card's typical rendered height (four lines when a preview // exists) so long card lists don't jump under the scroll thumb before -// self-measurement catches up. -const CARD_ROW_ESTIMATE_PX = 74 +// self-measurement catches up. Kept at/above the wrapped-title worst case — +// see SESSION_CARD_ROW_ESTIMATE_PX (#88473). const DIVIDER_ESTIMATE_PX = 28 const OVERSCAN_ROWS = 12 @@ -96,7 +96,7 @@ export const VirtualSessionList: FC = ({ return DIVIDER_ESTIMATE_PX } - return card ? CARD_ROW_ESTIMATE_PX : sessionRowEstimate(density) + return card ? SESSION_CARD_ROW_ESTIMATE_PX : sessionRowEstimate(density) }, getItemKey: index => { const row = listRows[index] @@ -109,9 +109,10 @@ export const VirtualSessionList: FC = ({ overscan: OVERSCAN_ROWS }) - // Rows are measured after paint, so changing density must invalidate cached - // measurements from the previous mode before off-screen rows re-enter. - useEffect(() => virtualizer.measure(), [density, virtualizer]) + // Rows are measured after paint, so changing density OR toggling Inbox + // cards must invalidate cached measurements from the previous mode before + // off-screen rows re-enter (#88473). + useEffect(() => virtualizer.measure(), [card, density, virtualizer]) const virtualItems = virtualizer.getVirtualItems() const totalSize = virtualizer.getTotalSize() diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 771b92bb34..b2c9c84d25 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -747,4 +747,114 @@ describe('useModelControls', () => { expect(queryClient.getQueryData(ambientAKey)).toMatchObject({ model: 'model-a', provider: 'provider-a' }) expect(notifyError).toHaveBeenCalled() }) + + // ── Stale MoA pick (#90244) ─────────────────────────────────────────────── + // The composer pill kept reading `Model · moa: default` after every MoA + // preset was disabled: a manual pick is sticky by design, but the virtual + // `moa` provider's catalog row disappears entirely once no preset is + // enabled — that one absence is authoritative, so the pick reseeds from + // the profile default instead of persisting forever. + it('reseeds a manual moa pick when the catalog no longer carries it (#90244)', async () => { + const queryClient = new QueryClient() + setCurrentModel('default') + setCurrentProvider('moa') + setCurrentModelSource('manual') + // Populated catalog without a moa row: every preset disabled. + queryClient.setQueryData(modelOptionsQueryKey('default'), { + model: 'openai/gpt-5.5', + provider: 'openai', + providers: [{ models: ['gpt-5.5'], name: 'OpenAI', slug: 'openai' }] + }) + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai' }) + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + await act(() => result.current.refreshCurrentModel()) + + expect($currentModel.get()).toBe('openai/gpt-5.5') + expect($currentProvider.get()).toBe('openai') + expect(getCurrentModelSource()).toBe('default') + }) + + it('keeps a manual moa pick while the catalog still offers the preset', async () => { + const queryClient = new QueryClient() + setCurrentModel('balanced') + setCurrentProvider('moa') + setCurrentModelSource('manual') + queryClient.setQueryData(modelOptionsQueryKey('default'), { + model: 'openai/gpt-5.5', + provider: 'openai', + providers: [{ models: ['default', 'balanced'], name: 'Mixture of Agents', slug: 'moa' }] + }) + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai' }) + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + await act(() => result.current.refreshCurrentModel()) + + expect($currentModel.get()).toBe('balanced') + expect($currentProvider.get()).toBe('moa') + expect(getCurrentModelSource()).toBe('manual') + }) + + it('keeps a manual moa pick when the catalog has not loaded yet', async () => { + const queryClient = new QueryClient() + setCurrentModel('default') + setCurrentProvider('moa') + setCurrentModelSource('manual') + // Empty cache AND a catalog dispatcher that fails: absence of data must + // never read as "the preset was removed". + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai' }) + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn(() => Promise.reject(new Error('gateway unavailable'))) + }) + ) + + await act(() => result.current.refreshCurrentModel()) + + expect($currentModel.get()).toBe('default') + expect($currentProvider.get()).toBe('moa') + expect(getCurrentModelSource()).toBe('manual') + }) + + it('never reseeds an ordinary manual pick the catalog lacks (custom slug)', async () => { + const queryClient = new QueryClient() + setCurrentModel('my-own-slug') + setCurrentProvider('custom') + setCurrentModelSource('manual') + queryClient.setQueryData(modelOptionsQueryKey('default'), { + model: 'openai/gpt-5.5', + provider: 'openai', + providers: [{ models: ['gpt-5.5'], name: 'OpenAI', slug: 'openai' }] + }) + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai' }) + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + await act(() => result.current.refreshCurrentModel()) + + // d595e636c83: a picked id is never rewritten to a catalog neighbour — + // the moa exception must not leak into the general design. + expect($currentModel.get()).toBe('my-own-slug') + expect($currentProvider.get()).toBe('custom') + expect(getCurrentModelSource()).toBe('manual') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 2d42bbca1f..9561d9b909 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -7,7 +7,7 @@ import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' import { isBusySessionModelSwitch } from '@/lib/gateway-rpc' import { surfaceModelSwitchConfirm } from '@/lib/guarded-model-switch' -import { modelOptionsQueryKey } from '@/lib/model-options' +import { moaPickRemoved, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { notifyError } from '@/store/notifications' import { $activeGatewayProfile } from '@/store/profile' import { @@ -110,60 +110,97 @@ export function useModelControls({ // only fills an EMPTY selection so a user's pick (plain UI state in // $currentModel) survives the lifecycle refreshes that fire on boot / fresh // draft / session events. A live session owns the footer, so skip entirely. - const refreshCurrentModel = useCallback(async (force = false) => { - // A forced profile swap opens a new intent epoch; an older in-flight - // response for a previous profile must stand down when it resolves. - if (force) { - profileRefreshEpochRef.current += 1 - } - - const profileRefreshEpoch = profileRefreshEpochRef.current - const profile = $activeGatewayProfile.get() - - try { - if ($activeSessionId.get()) { - return + const refreshCurrentModel = useCallback( + async (force = false) => { + // A forced profile swap opens a new intent epoch; an older in-flight + // response for a previous profile must stand down when it resolves. + if (force) { + profileRefreshEpochRef.current += 1 } - // A manual pick is sticky. It is never diffed against the catalog: rows - // are hints, and a custom slug the row lacks is still the user's choice - // (the gateway validates it on switch). - const keepManualPick = () => !force && Boolean($currentModel.get()) && getCurrentModelSource() === 'manual' + const profileRefreshEpoch = profileRefreshEpochRef.current + const profile = $activeGatewayProfile.get() - if (keepManualPick()) { - return + try { + if ($activeSessionId.get()) { + return + } + + // A manual pick is sticky. It is never diffed against the catalog: rows + // are hints, and a custom slug the row lacks is still the user's choice + // (the gateway validates it on switch). ONE exception, narrower than a + // catalog diff: a pick pointing at the virtual `moa` provider, whose row + // the catalog omits entirely once no preset is enabled — that absence is + // authoritative, and without the exception the pill reads + // `Model · moa: default` forever (#90244). + const manualPick = () => Boolean($currentModel.get()) && getCurrentModelSource() === 'manual' + + const staleMoaPick = () => + !force && manualPick() && ($currentProvider.get() || '').trim().toLowerCase() === 'moa' + + if (manualPick() && !force && !staleMoaPick()) { + return + } + + // Snapshot the selection generation before awaiting so a picker click + // that lands while getGlobalModelInfo is in flight wins over this older + // default — value comparisons alone miss re-selecting the same row. + const selectionGeneration = getComposerSelectionGeneration() + + // Judge the moa pick against the catalog: peek the picker's own cache + // first and only fetch (deduped with the in-flight UI query) when it is + // empty, so the pill reseeds even before the chat view mounts its query. + // A catalog that fails to load keeps the pick — absence of data is not + // absence of the preset. + let reseedStaleMoa = false + + if (staleMoaPick()) { + const catalogProfile = cacheProfile || profile + const catalogKey = modelOptionsQueryKey(catalogProfile, null, cacheOwnerConnectionId) + + const catalog = + queryClient.getQueryData(catalogKey) ?? + (await queryClient.fetchQuery({ + queryKey: catalogKey, + queryFn: (): Promise => + requestModelOptions({ profile: catalogProfile, request: requestGateway }) + })) + + reseedStaleMoa = moaPickRemoved(catalog, 'moa', $currentModel.get()) + + if (!reseedStaleMoa) { + return + } + } + + const result = await getGlobalModelInfo(profile) + + if ( + profileRefreshEpochRef.current !== profileRefreshEpoch || + $activeSessionId.get() || + getComposerSelectionGeneration() !== selectionGeneration || + (manualPick() && !force && !reseedStaleMoa) + ) { + return + } + + if (typeof result.model === 'string') { + setCurrentModel(result.model) + } + + if (typeof result.provider === 'string') { + setCurrentProvider(result.provider) + } + + if (typeof result.model === 'string' || typeof result.provider === 'string') { + setCurrentModelSource('default') + } + } catch { + // The delayed session.info event still updates this once the agent is ready. } - - // Snapshot the selection generation before awaiting so a picker click - // that lands while getGlobalModelInfo is in flight wins over this older - // default — value comparisons alone miss re-selecting the same row. - const selectionGeneration = getComposerSelectionGeneration() - const result = await getGlobalModelInfo(profile) - - if ( - profileRefreshEpochRef.current !== profileRefreshEpoch || - $activeSessionId.get() || - getComposerSelectionGeneration() !== selectionGeneration || - keepManualPick() - ) { - return - } - - if (typeof result.model === 'string') { - setCurrentModel(result.model) - } - - if (typeof result.provider === 'string') { - setCurrentProvider(result.provider) - } - - if (typeof result.model === 'string' || typeof result.provider === 'string') { - setCurrentModelSource('default') - } - } catch { - // The delayed session.info event still updates this once the agent is ready. - } - }, []) + }, + [cacheOwnerConnectionId, cacheProfile, queryClient, requestGateway] + ) // Drop a sticky composer pick so new chats follow Settings → Model again, // without making the user re-apply the default they already have (#107410). 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 60a70b6427..a6545a2604 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -286,3 +286,86 @@ describe('in-flight local downloads', () => { expect(screen.queryByText('Local')).toBeNull() }) }) + +// A row shows its model's effort ("Gemini 3.1 Pro Max"), which reads as a +// fixed model+effort combo unless the row also advertises that the effort is +// editable behind it. The caret is that advertisement, and ArrowRight is the +// way in for anyone not driving the menu with a mouse (#86966). +describe('the per-row options submenu is discoverable', () => { + it('marks each model row as opening a submenu', async () => { + renderMenu() + + const row = await screen.findByText(/Gemini 3\.1 Pro/i) + const trigger = row.closest('[data-slot="dropdown-menu-sub-trigger"]') + + expect(trigger).not.toBeNull() + expect(trigger?.querySelector('.codicon-chevron-right')).not.toBeNull() + }) + + it('opens the highlighted row with ArrowRight, so effort is reachable without a mouse', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) + + // Nothing is selected yet, so highlight the first row before opening it. + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowRight' }) + + expect(await screen.findByText('Effort')).not.toBeNull() + expect(screen.getByRole('menuitemradio', { name: 'Extra High' })).not.toBeNull() + }) + + it('returns focus to the search field when the keyboard closes the sub again', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowRight' }) + await screen.findByText('Effort') + + // ArrowLeft, not Escape: inside a sub, Escape dismisses the whole menu. + fireEvent.keyDown(screen.getByText('Effort'), { key: 'ArrowLeft' }) + + await waitFor(() => expect(input.ownerDocument.activeElement).toBe(input)) + }) + + // That round trip is owed by the row we opened and by no other. Once the + // pointer takes the menu over, Radix closes the keyboard-opened sub without + // handing focus back — reclaiming it there would pull focus out from under + // an interaction already in progress. + it('leaves focus alone when the pointer takes over from a keyboard-opened sub', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowRight' }) + await screen.findByText('Effort') + + const hovered = screen.getByText(/Gemini 2\.5 Flash/i).closest('[data-slot="dropdown-menu-sub-trigger"]') + + fireEvent.pointerMove(hovered as Element, { pointerType: 'mouse' }) + await waitFor(() => expect(hovered?.getAttribute('data-state')).toBe('open')) + + expect(input.ownerDocument.activeElement).not.toBe(input) + }) + + it('leaves ArrowRight to the search field while the caret is inside the query', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) as HTMLInputElement + + fireEvent.change(input, { target: { value: 'gemini' } }) + input.setSelectionRange(0, 0) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowRight' }) + + expect(screen.queryByText('Effort')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 03189bee8d..316f6d1b6a 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -445,6 +445,85 @@ export function ModelCatalogMenu({ closeMenu() } + // ── Keyboard path into a row's edit submenu (#86966) ───────────────────── + // Rows are HIGHLIGHTED, not DOM-focused (focus stays in the search input so + // typing keeps working), which is why Radix's own ArrowRight-on-the-trigger + // never fires. ArrowRight therefore hands focus to the highlighted trigger + // and replays the key there: from that point Radix owns everything — opening + // the sub, focusing its first item, and returning focus to the trigger when + // ArrowLeft closes it. `handleSubOpenChange` below finishes that round trip + // by putting focus back in the search field. (Escape is not part of it: + // inside a sub, Radix dismisses the WHOLE menu rather than just the sub.) + // + // ArrowRight is hard-coded rather than direction-aware because Radix's own + // binding is: the app installs no `DirectionProvider` and passes no `dir`, + // so `useDirection` resolves to `ltr` and Radix opens subs on ArrowRight in + // every locale, RTL included. Matching that keeps the two in step; whoever + // wires up a `DirectionProvider` has to teach this handler the same + // direction Radix reads, or the sub goes unreachable again in Arabic. + // WHICH row we opened from the keyboard, not merely THAT we opened one: a + // bare flag is still set when a keyboard-opened sub closes because the mouse + // moved on to another row, and refocusing search there pulls focus out from + // under a pointer interaction that has already taken the menu over. + const keyboardSubRef = useRef(null) + + const openActiveSubmenu = (): boolean => { + const trigger = listRef.current?.querySelector('[data-kb-active]') + + if (!trigger || !kbActiveKey) { + return false + } + + keyboardSubRef.current = { key: kbActiveKey, trigger } + trigger.focus() + trigger.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' })) + + // Highlight also lands on rows that are plain items rather than submenu + // triggers (the MoA presets), which swallow the key; an open can also be + // interrupted. Either way, don't strand focus on a row where typing no + // longer reaches the search field. + requestAnimationFrame(() => { + // Only while the claim is still ours and untouched: a sub that opened + // and closed inside this one frame has already been handled below, and + // a stale frame reaching in afterwards would move focus twice. + if (keyboardSubRef.current?.trigger !== trigger) { + return + } + + if (trigger.getAttribute('data-state') !== 'open') { + keyboardSubRef.current = null + searchRef.current?.focus() + } + }) + + return true + } + + // Only the sub THIS row opened from the keyboard owes focus back to the + // search field; during mouse use focus never left it, and hover open/close + // fires constantly. + const handleSubOpenChange = (open: boolean, key: string) => { + const claim = keyboardSubRef.current + + if (open || claim?.key !== key) { + return + } + + keyboardSubRef.current = null + + // Deferred one frame because Radix restores focus to the trigger straight + // AFTER this callback — and that restore is the signal we need. Radix does + // it only for a keyboard close (ArrowLeft); a sub closed because the + // pointer moved to another row leaves focus where it fell, and grabbing it + // then would fight the mouse. So finish the round trip only if the trigger + // really is holding focus. + requestAnimationFrame(() => { + if (document.activeElement === claim.trigger) { + searchRef.current?.focus() + } + }) + } + // Keep the selected row in view while arrowing through the scrollable list. const listRef = useRef(null) @@ -479,6 +558,13 @@ export function ModelCatalogMenu({ event.preventDefault() event.stopPropagation() commitKbRow() + } else if (event.key === 'ArrowRight' && caretAtEnd(event.currentTarget)) { + // Claimed only with the caret parked at the end of the query, where + // ArrowRight has nothing left to do as a text cursor. + if (openActiveSubmenu()) { + event.preventDefault() + event.stopPropagation() + } } }} onValueChange={value => { @@ -589,7 +675,10 @@ export function ModelCatalogMenu({ // Clicking the row commits the model and closes; the edit // submenu (reasoning/fast) is reached by HOVER, so you can - // tweak those without the click dismissing everything. + // tweak those without the click dismissing everything. The + // trailing caret is what advertises that submenu — without + // it the row's effort badge reads as a fixed model+effort + // combo rather than an editable setting (#86966). const activate = () => { if (!isCurrent) { void selectFamily(family, group.provider) @@ -599,9 +688,11 @@ export function ModelCatalogMenu({ } return ( - + handleSubOpenChange(open, `${group.provider.slug}:${family.id}`)} + > { if (event.key === 'Enter' || event.key === ' ') { @@ -774,6 +865,14 @@ export function ModelCatalogMenu({ /** Re-exported so callers building a footer row match the catalog's rows. */ export { dropdownMenuRow } +/** True when the text cursor sits at the very end with nothing selected — the + * only state where ArrowRight is free for the menu to claim. */ +function caretAtEnd(input: HTMLInputElement): boolean { + const { selectionEnd, selectionStart, value } = input + + return selectionStart === value.length && selectionEnd === value.length +} + // The backend's provider row for staged local models (inventory.py's // _local_runtime_row). Downloads-in-flight attach to this group. const LOCAL_PROVIDER_SLUG = 'llamacpp' diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 6d0505c57e..d786c9bf0d 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -151,7 +151,7 @@ describe('ModelMenuPanel current selection', () => { const { content } = renderPanel() const currentRow = (await content.findByText(/Gemini 3\.1 Pro/i)).closest('[role="menuitem"]') - const staleRow = content.getByText('Deepseek Chat').closest('[role="menuitem"]') + const staleRow = content.getByText('DeepSeek Chat').closest('[role="menuitem"]') expect(currentRow?.querySelector('.codicon-check')).not.toBeNull() expect(staleRow?.querySelector('.codicon-check')).toBeNull() @@ -178,7 +178,7 @@ describe('ModelMenuPanel search', () => { $currentModel.set('deepseek-v4-pro') const { content } = renderPanel() - await content.findByText(/Deepseek V4 Pro/i) + await content.findByText(/DeepSeek V4 Pro/i) const input = screen.getByRole('textbox', { name: 'Search models' }) fireEvent.change(input, { target: { value: 'gemini' } }) @@ -186,7 +186,7 @@ describe('ModelMenuPanel search', () => { await vi.waitFor(() => { expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull() }) - expect(rowWithText(content, /Deepseek V4 Pro/i)).toBeNull() + expect(rowWithText(content, /DeepSeek V4 Pro/i)).toBeNull() }) it('Enter in the search field commits the first match', async () => { @@ -350,11 +350,11 @@ describe('ModelMenuPanel provider collapse', () => { const header = await content.findByText('DeepSeek') // Collapse fireEvent.click(header) - expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + expect(content.queryByText('DeepSeek V4 Pro')).toBeNull() // Expand fireEvent.click(header) await vi.waitFor(() => { - expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull() + expect(content.queryByText('DeepSeek V4 Pro')).not.toBeNull() }) }) @@ -369,7 +369,7 @@ describe('ModelMenuPanel provider collapse', () => { // The current provider is collapsible like any other — clicking its header // hides its models rather than forcing them to stay open. await vi.waitFor(() => { - expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + expect(content.queryByText('DeepSeek V4 Pro')).toBeNull() }) }) @@ -378,7 +378,7 @@ describe('ModelMenuPanel provider collapse', () => { const header = await content.findByText('DeepSeek') fireEvent.click(header) - expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + expect(content.queryByText('DeepSeek V4 Pro')).toBeNull() // Type in the search bar (auto-focused by DropdownMenuSearch) const input = screen.getByRole('textbox', { name: 'Search models' }) @@ -396,7 +396,7 @@ describe('ModelMenuPanel provider collapse', () => { (_, element) => element?.tagName === 'SPAN' && !element.querySelector('span') && - (element.textContent ?? '').startsWith('Deepseek V4 Pro') + (element.textContent ?? '').startsWith('DeepSeek V4 Pro') ) ).not.toBeNull() }) @@ -409,7 +409,7 @@ describe('ModelMenuPanel provider collapse', () => { // Radix DropdownMenuItem fires onSelect on Enter from the onKeyDown handler fireEvent.keyDown(header.closest('[role="menuitem"]') ?? header, { key: 'Enter' }) - expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + expect(content.queryByText('DeepSeek V4 Pro')).toBeNull() }) // The collapsed-providers set is a global presentation preference @@ -482,7 +482,7 @@ describe('ModelMenuPanel provider collapse', () => { const { content, onSelectModel } = renderPanel() - await content.findByText(/Glm 4\.5 Air/i) + await content.findByText(/GLM 4.5 Air/i) fireEvent.click(await content.findByText('Refresh models')) @@ -512,7 +512,7 @@ describe('ModelMenuPanel provider collapse', () => { const { content, onSelectModel } = renderPanel() - await content.findAllByText(/Glm 4\.5 Air/i) + await content.findAllByText(/GLM 4.5 Air/i) fireEvent.click(await content.findByText('Refresh models')) await vi.waitFor(() => { @@ -536,7 +536,7 @@ describe('ModelMenuPanel provider collapse', () => { const { content, onSelectModel } = renderPanel() - const rows = await content.findAllByText(/Glm 4\.5 Air/i) + const rows = await content.findAllByText(/GLM 4.5 Air/i) const items = [...new Set(rows.map(row => row.closest('[role="menuitem"]')))] expect(items).toHaveLength(2) diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index 57220790b7..9ccbf6bb6d 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelOptions } from '@/hermes' -import { catalogProviderMatches, modelOptionsQueryKey, requestModelOptions } from './model-options' +import { catalogProviderMatches, moaPickRemoved, modelOptionsQueryKey, requestModelOptions } from './model-options' const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] } @@ -222,3 +222,38 @@ describe('catalogProviderMatches', () => { expect(catalogProviderMatches(cloudflare, 'openrouter')).toBe(false) }) }) + +describe('moaPickRemoved', () => { + const providers = [ + { models: ['deepseek-v4-pro'], name: 'DeepSeek', slug: 'deepseek' }, + { models: ['default', 'balanced'], name: 'Mixture of Agents', slug: 'moa' } + ] + + it('flags a manual moa pick when the populated catalog has no moa row (#90244)', () => { + const noMoa = [providers[0]] + expect(moaPickRemoved({ providers: noMoa }, 'moa', 'default')).toBe(true) + }) + + it('flags a manual moa pick whose preset the moa row no longer lists', () => { + expect(moaPickRemoved({ providers }, 'moa', 'retired-preset')).toBe(true) + }) + + it('keeps a manual moa pick while the catalog still offers the preset', () => { + expect(moaPickRemoved({ providers }, 'moa', 'default')).toBe(false) + expect(moaPickRemoved({ providers }, 'MOA', 'balanced')).toBe(false) + }) + + it('never clobbers while the catalog is unavailable or loading', () => { + expect(moaPickRemoved(undefined, 'moa', 'default')).toBe(false) + expect(moaPickRemoved({ providers: [] }, 'moa', 'default')).toBe(false) + expect(moaPickRemoved({ providers: undefined }, 'moa', 'default')).toBe(false) + }) + + it('leaves every non-moa provider to the sticky-pick design', () => { + // A custom slug the catalog lacks is the user's choice, not a removal + // (d595e636c83: picks are never retargeted from catalog membership). + expect(moaPickRemoved({ providers: [providers[0]] }, 'deepseek', 'deepseek-v4.1-flash')).toBe(false) + expect(moaPickRemoved({ providers: [providers[0]] }, 'custom', 'my-own-slug')).toBe(false) + expect(moaPickRemoved({ providers: [providers[0]] }, '', 'default')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index 6ed2cf2f81..0d5bc7ac99 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -36,6 +36,34 @@ export function currentModelCapabilities( // `deepseek-v4.1-flash` for the row's `-0731` sibling. The only authority on a // pick's validity is the gateway's switch result. +/** The single, deliberate exception to the sticky-pick rule above: the virtual + * `moa` provider. Its catalog row vanishes entirely once no MoA preset is + * enabled (`hermes_cli/inventory.py` filters it out of explicit-only + * catalogs), so a persisted manual pick pointing at it leaves the composer + * pill reading `Model · moa: default` forever (#90244). For this one provider + * — and only with a populated catalog in hand — row absence is authoritative: + * the pick reseeds from the profile default. Every other provider keeps the + * sticky behavior; an unloaded/empty catalog never clobbers anything. */ +export function moaPickRemoved( + options: { providers?: ModelOptionProvider[] | null } | null | undefined, + provider: string, + model: string +): boolean { + if (!model.trim() || provider.trim().toLowerCase() !== 'moa') { + return false + } + + const providers = options?.providers + + if (!providers || providers.length === 0) { + return false + } + + const row = providers.find(p => (p.slug || p.name || '').toLowerCase() === 'moa') + + return !(row?.models ?? []).includes(model) +} + interface ModelOptionsRequest { /** When false, include ambient/unconfigured providers (onboarding/setup * surfaces). Chat pickers default to true so only explicitly configured diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index 4ecac133b1..d995a471bc 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -27,13 +27,51 @@ describe('model-status-label', () => { expect(modelDisplayParts('anthropic/claude-opus-4.8-fast').tag).toBe('Fast') }) + it('keeps the vendor casing the model id does not carry (#85849)', () => { + expect(displayModelName('glm-5.2')).toBe('GLM 5.2') + expect(displayModelName('zai-org/glm-5.1')).toBe('GLM 5.1') + expect(displayModelName('deepseek-v4-flash')).toBe('DeepSeek V4 Flash') + expect(displayModelName('minimax/minimax-01')).toBe('MiniMax 01') + expect(displayModelName('xiaomi/mimo-v2.5')).toBe('MiMo V2.5') + expect(displayModelName('ernie-5.1')).toBe('ERNIE 5.1') + expect(displayModelName('baai/bge-m3')).toBe('BGE M3') + expect(displayModelName('openai')).toBe('OpenAI') + }) + + it('capitalises parameter counts the way vendors write them (#85849)', () => { + expect(displayModelName('qwen3-32b')).toBe('Qwen3 32B') + expect(displayModelName('qwen/qwen3.5-35b-a3b')).toBe('Qwen3.5 35B A3B') + expect(displayModelName('meta/llama-3.1-8b-instruct')).toBe('Llama 3.1 8B Instruct') + expect(displayModelName('llama-3.1-8b-instruct-fp8')).toBe('Llama 3.1 8B Instruct FP8') + expect(displayModelName('gemma-4-26b-a4b-it')).toBe('Gemma 4 26B A4B IT') + expect(displayModelName('nemotron-nano-12b-v2-vl')).toBe('Nemotron Nano 12B V2 VL') + }) + + it('title-cases gemini names like every other branch (#85849)', () => { + expect(displayModelName('gemini-2.5-pro')).toBe('Gemini 2.5 Pro') + expect(displayModelName('gemini-2.0-flash')).toBe('Gemini 2.0 Flash') + expect(displayModelName('google/gemini-2.5-flash-lite')).toBe('Gemini 2.5 Flash Lite') + }) + it('keeps the model pill to name + Fast; the effort lives on its own pill', () => { expect(formatModelPillLabel('openai/gpt-5.5', { fastMode: true })).toBe('GPT-5.5 · Fast') - expect(formatModelPillLabel('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8 · Fast') + expect(formatModelPillLabel('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8 Fast') expect(formatModelPillLabel('openai/gpt-5.5')).toBe('GPT-5.5') expect(formatModelPillLabel('')).toBe('No model') }) + it('keeps the variant tag in the display name so distinct ids never collapse (#88597)', () => { + expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8 Fast') + expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('DeepSeek V4 Pro Thinking') + expect(displayModelName('gpt-5.5-preview')).toBe('GPT-5.5 Preview') + expect(displayModelName('claude-opus-5')).toBe('Opus 5') + // A base model and its variant must NEVER share a display label. + expect(displayModelName('claude-opus-5')).not.toBe(displayModelName('claude-opus-5-thinking')) + // The quant/contextWindow tags ride along the same way. + expect(displayModelName('Qwen3.6-27B-UD-Q4_K_XL')).toBe('Qwen3.6 27B Q4') + expect(displayModelName('claude-sonnet-5[1m]')).toBe('Sonnet 5 1M') + }) + describe('currentPickerSelection', () => { const store = { model: 'opus', provider: 'anthropic' } const options = { model: 'hermes-4', provider: 'nous' } diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index c2c9b2d06d..8c3b369811 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -72,6 +72,40 @@ const VARIANT_TAGS: ReadonlyArray = [ const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim() +// Vendors write their own names in casing the model id does not carry, and +// title-casing the id overrides it: `glm-5.2` reads as "Glm 5.2" instead of +// "GLM 5.2" (#85849). Applied AFTER title-casing so the rule is one pass over +// a normalized string, and only ever to whole words — `Minimax` never touches +// a longer token that merely contains it. +const VENDOR_CASING: ReadonlyArray = [ + [/\bDeepseek\b/g, 'DeepSeek'], + [/\bGlm\b/g, 'GLM'], + [/\bMinimax\b/g, 'MiniMax'], + [/\bOpenai\b/g, 'OpenAI'], + [/\bErnie\b/g, 'ERNIE'], + [/\bMimo\b/g, 'MiMo'], + [/\bBge\b/g, 'BGE'], + [/\bVl\b/g, 'VL'], + [/\bIt\b/g, 'IT'], + [/\bFp8\b/g, 'FP8'], + [/\bAi\b/g, 'AI'] +] + +// Parameter counts and active-parameter counts: vendors write 8B, 235B, A22B — +// never 8b. Matched after title-casing (so the token reads "8b" or "A3b"), +// case-insensitively so the title-cased "A" of "A3b" is still a prefix. +const PARAMETER_COUNT = /\b(a?)(\d+(?:\.\d+)?)b\b/gi + +const applyVendorCasing = (text: string): string => { + let cased = text.replace(PARAMETER_COUNT, (_match, prefix: string, size: string) => `${prefix.toUpperCase()}${size}B`) + + for (const [pattern, replacement] of VENDOR_CASING) { + cased = cased.replace(pattern, replacement) + } + + return cased +} + function prettifyBase(base: string): string { if (/^deepseek-flash$/i.test(base)) { return 'DeepSeek V4.1 Flash' @@ -80,11 +114,13 @@ function prettifyBase(base: string): string { if (/^claude-/i.test(base)) { // Anthropic ids spell the version with hyphens (`haiku-4-5`, `fable-5-1`); // the human name is dotted ("Haiku 4.5"), not "Haiku 4 5". - return titleCase( - base - .replace(/^claude-/i, '') - .replace(/(\d)-(?=\d)/g, '$1.') - .replace(/-/g, ' ') + return applyVendorCasing( + titleCase( + base + .replace(/^claude-/i, '') + .replace(/(\d)-(?=\d)/g, '$1.') + .replace(/-/g, ' ') + ) ) } @@ -92,11 +128,13 @@ function prettifyBase(base: string): string { return base.replace(/^gpt-/i, 'GPT-') } + // Title-case this branch too: without it `gemini-2.5-pro` rendered as + // "Gemini 2.5 pro" — the only branch that left its words lowercase. if (/^gemini-/i.test(base)) { - return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ') + return applyVendorCasing(titleCase(base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' '))) } - return titleCase(base.replace(/-/g, ' ')) + return applyVendorCasing(titleCase(base.replace(/-/g, ' '))) } /** Split a model id into a clean display name plus an optional grayed variant @@ -144,22 +182,28 @@ export function modelDisplayParts(model: string): { name: string; tag: string } return { name: prettifyBase(base) || model.trim() || 'No model', tag } } -/** Friendly one-line model name for menus and the status bar. */ +/** Friendly one-line model name for menus and the status bar. The variant + * tag is part of the name: `…-4.8` vs `…-4.8-thinking` must never collapse + * to the same label on any surface (#88597). */ export function displayModelName(model: string): string { - return modelDisplayParts(model).name + const { name, tag } = modelDisplayParts(model) + + return tag ? `${name} ${tag}` : name } /** Composer model-pill label — model name plus Fast when it applies. The * reasoning level is NOT here: it has its own pill (`ReasoningPill`), so a * long model name can no longer push the effort out of the truncating span. */ export function formatModelPillLabel(model: string, options?: { fastMode?: boolean }): string { - const name = displayModelName(model) + const label = displayModelName(model) // Fast is shown when the speed=fast param is on (options.fastMode) OR the - // active model is a `…-fast` variant (fast via a separate model id). - if (model.trim() && (options?.fastMode || /-fast$/i.test(modelBaseId(model)))) { - return `${name} · Fast` + // active model is a `…-fast` variant (fast via a separate model id). The + // variant's tag already reads Fast in the label above, so only the + // param-driven case appends it — never both (#88597). + if (model.trim() && options?.fastMode && !/-fast$/i.test(modelBaseId(model))) { + return `${label} · Fast` } - return name + return label } diff --git a/hermes_state_search.py b/hermes_state_search.py index 553c39f353..25cfbf68dd 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -106,6 +106,30 @@ def _like_params(term: str) -> List[str]: return [f"%{_escape_like(term)}%"] * 3 +def _strip_cjk_wildcards(raw_query: str) -> str: + """Drop the trailing prefix wildcard callers append for ASCII ("nimb" -> "nimb*"). + + None of the CJK routes can honour that star: the bigram and trigram routes + quote every token before MATCH (so ``*`` matches a literal asterisk) and + LIKE has no ``*`` wildcard at all (only ``%``/``_``). Left in place, every + CJK search arriving from the web/desktop search box — which appends the + star to each unquoted token so partial English words match — searches for + a term ending in a literal ``*`` and returns nothing (#90636). Only + TRAILING stars go: a star written inside a quoted phrase is the user's + own text, and a token that is ALL stars keeps its original form so it + cannot degrade to a match-everything empty term. + """ + if "*" not in raw_query: + return raw_query + stripped: List[str] = [] + for token in raw_query.split(): + if token.upper() in _FTS_OPERATORS: + stripped.append(token) + else: + stripped.append(token.rstrip("*") or token) + return " ".join(stripped) or raw_query + + def _flatten_text(decoded: Any) -> str: """Multimodal part list -> joined text (or the placeholder); str passes through; else ''.""" if isinstance(decoded, list): @@ -1171,7 +1195,7 @@ class SessionSearchMixin: 1-char CJK runs (bigrams only exist for runs >=2 — LIKE is broader); then trigram (>=3 CJK chars per token); then a LIKE substring scan with one clause per non-operator token so "广西 OR 桂林 OR 漓江" matches each term.""" - raw_query = query.strip('"').strip() + raw_query = _strip_cjk_wildcards(query).strip('"').strip() match_query = _quote_fts_tokens(raw_query) if self._fts_cjk_available and not wants_unindexed_rows and not self._has_lone_cjk_run(raw_query): matches = self._match_rows( diff --git a/tests/test_search_cjk_prefix_wildcard.py b/tests/test_search_cjk_prefix_wildcard.py new file mode 100644 index 0000000000..4887630b70 --- /dev/null +++ b/tests/test_search_cjk_prefix_wildcard.py @@ -0,0 +1,93 @@ +"""CJK search must survive the prefix wildcard callers append (#90636). + +The web/desktop search endpoint turns every unquoted token into ``token*`` so +partial English words match. None of the CJK routes can honour that star: the +bigram and trigram routes quote each token before ``MATCH`` (so ``*`` is matched +literally) and the LIKE route has no ``*`` wildcard at all (only ``%``/``_``). +Left in place, every CJK search typed into the desktop/web search box becomes +a search for a term ending in a literal asterisk and returns nothing, while +the identical query without the star returns rows. + +Runs against a real SessionDB in a temp HERMES_HOME, on the trigram and LIKE +routes only — no ``cjk_unicode61`` tokenizer toolchain is required. +""" + +from hermes_state import SessionDB + +TWO_CHAR = "秃发" # 2 CJK chars — below the trigram threshold, LIKE route +FOUR_CHAR = "秃发应对" # 4 CJK chars — trigram-eligible + + +def make_db(tmp_path): + database = SessionDB(db_path=tmp_path / "state.db") + session_id = "20260820_000001_cjk001" + database.create_session(session_id, "cli") + database.append_message(session_id, "user", "关于秃发应对的讨论内容,请总结") + database.append_message(session_id, "assistant", "hello nimby world") + return database + + +def hits(database, query): + return database.search_messages(query=query, limit=10, fields=("session_id", "snippet")) + + +def test_trailing_star_matches_the_bare_query(tmp_path): + """The star must widen or keep the result set, never empty it.""" + database = make_db(tmp_path) + try: + for term in (TWO_CHAR, FOUR_CHAR): + bare = hits(database, term) + starred = hits(database, term + "*") + assert bare, f"{term!r} should match the seeded message" + assert len(starred) == len(bare), ( + f"{term + '*'!r} returned {len(starred)} rows vs {len(bare)} for {term!r} — " + "the caller-appended prefix wildcard is being matched literally" + ) + finally: + database.close() + + +def test_star_is_stripped_per_token_in_boolean_queries(tmp_path): + """A multi-token CJK OR query keeps working with a wildcard per token.""" + database = make_db(tmp_path) + try: + assert hits(database, "秃发* OR 桂林*") + finally: + database.close() + + +def test_ascii_prefix_wildcard_still_works(tmp_path): + """The normalization is CJK-only; the ASCII prefix search is untouched.""" + database = make_db(tmp_path) + try: + assert hits(database, "nimb*") + finally: + database.close() + + +def test_a_lone_star_is_not_turned_into_a_match_all(tmp_path): + """Stripping must not leave an empty term that matches every row.""" + database = make_db(tmp_path) + try: + assert hits(database, "*") == [] + finally: + database.close() + + +def test_quoted_cjk_phrase_survives_the_star(tmp_path): + """The quoted workaround from the report still matches once starred.""" + database = make_db(tmp_path) + try: + assert hits(database, '"秃发"' + "*") + assert hits(database, '"秃发"') + finally: + database.close() + + +def test_mixed_cjk_and_ascii_query_survives_the_star(tmp_path): + database = make_db(tmp_path) + try: + found = hits(database, "秃发* nimby*") + assert found + finally: + database.close()