fix: picker/input cluster — vendor casing, variant tags, submenu keyboard path, stale moa pick, CJK search star

Six fixes for the wave-7 picker/input cluster:

DeepSeek -> "Deepseek") and left the gemini- branch's words lowercase
("Gemini 2.5 pro"). Vendor casing + parameter counts now applied after
title-case (GLM, DeepSeek, MiniMax, OpenAI, ERNIE, MiMo, BGE, VL, IT,
FP8, AI; 8b -> 8B, a3b -> A3B), and the gemini branch title-cases like
every other branch.

and was unreachable by keyboard (rows are highlighted, never DOM-focused,
so Radix's own ArrowRight never fires). The chevron is now visible on
every model row and ArrowRight (caret parked at query end) hands focus
to the highlighted trigger and opens its sub; ArrowLeft returns focus
to the search field. Consolidates #86968 + #104532.

-fast/-thinking/-preview ids to the base label. The tag now rides the
display name on every surface, and formatModelPillLabel no longer
doubles Fast for a -fast variant id.

all MoA presets were disabled: manual picks are sticky by design
(d595e636c8), but the virtual moa provider's catalog row disappears
entirely once no preset is enabled, so that one absence is
authoritative (moaPickRemoved) and the pick reseeds from the profile
default. Narrow moa-only exception — no general catalog diff.

token (nimb -> nimb*); none of the CJK routes can honour it (bigram and
trigram routes quote tokens so the star matches literally; LIKE has no
star wildcard at all), so CJK searches returned zero results. The star
is now stripped per token on the CJK path only.

measure() effect deps (stale measurements after toggling Inbox style)
and the card estimate undershot the four-line/wrapped-title worst case
(74px), painting rows over their neighbours on cold start. Card
estimate raised to the worst-case-covering 96px and the deps fixed.
This commit is contained in:
Brooklyn Nicholson
2026-09-24 17:00:03 -05:00
committed by brooklyn!
parent 13f6b46daa
commit 50873d2154
14 changed files with 816 additions and 90 deletions

View File

@@ -17,6 +17,16 @@ const oneLine = (value: null | string) => value?.replace(/\s+/g, ' ').trim() ||
export const sessionRowEstimate = (density: SessionListDensity) => export const sessionRowEstimate = (density: SessionListDensity) =>
({ compact: 28, comfortable: 45, detailed: 63 })[density] ({ 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 { export function sessionRowDetails(session: SessionInfo, fmt: SessionRowFormatters): SessionRowDetails {
const preview = oneLine(session.preview) const preview = oneLine(session.preview)
const hasOwnTitle = Boolean(session.title?.trim()) const hasOwnTitle = Boolean(session.title?.trim())

View File

@@ -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<Parameters<typeof VirtualSessionList>[0]> = {}) {
return render(<VirtualSessionList {...defaultProps} {...props} />)
}
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(<VirtualSessionList {...defaultProps} />)
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(<VirtualSessionList {...defaultProps} card />)
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(<VirtualSessionList {...defaultProps} card />)
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
)
})
})

View File

@@ -15,7 +15,7 @@ import { $sessionListDensity } from '@/store/session-list-density'
import { SidebarDateDivider } from './chrome' import { SidebarDateDivider } from './chrome'
import { SidebarSessionRow } from './session-row' import { SidebarSessionRow } from './session-row'
import { sessionRowEstimate } from './session-row-details' import { SESSION_CARD_ROW_ESTIMATE_PX, sessionRowEstimate } from './session-row-details'
interface SessionRowCommonProps { interface SessionRowCommonProps {
branchStem?: string branchStem?: string
@@ -60,8 +60,8 @@ export interface VirtualSessionListProps {
// Matches the card's typical rendered height (four lines when a preview // 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 // exists) so long card lists don't jump under the scroll thumb before
// self-measurement catches up. // self-measurement catches up. Kept at/above the wrapped-title worst case —
const CARD_ROW_ESTIMATE_PX = 74 // see SESSION_CARD_ROW_ESTIMATE_PX (#88473).
const DIVIDER_ESTIMATE_PX = 28 const DIVIDER_ESTIMATE_PX = 28
const OVERSCAN_ROWS = 12 const OVERSCAN_ROWS = 12
@@ -96,7 +96,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
return DIVIDER_ESTIMATE_PX return DIVIDER_ESTIMATE_PX
} }
return card ? CARD_ROW_ESTIMATE_PX : sessionRowEstimate(density) return card ? SESSION_CARD_ROW_ESTIMATE_PX : sessionRowEstimate(density)
}, },
getItemKey: index => { getItemKey: index => {
const row = listRows[index] const row = listRows[index]
@@ -109,9 +109,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
overscan: OVERSCAN_ROWS overscan: OVERSCAN_ROWS
}) })
// Rows are measured after paint, so changing density must invalidate cached // Rows are measured after paint, so changing density OR toggling Inbox
// measurements from the previous mode before off-screen rows re-enter. // cards must invalidate cached measurements from the previous mode before
useEffect(() => virtualizer.measure(), [density, virtualizer]) // off-screen rows re-enter (#88473).
useEffect(() => virtualizer.measure(), [card, density, virtualizer])
const virtualItems = virtualizer.getVirtualItems() const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize() const totalSize = virtualizer.getTotalSize()

View File

@@ -747,4 +747,114 @@ describe('useModelControls', () => {
expect(queryClient.getQueryData(ambientAKey)).toMatchObject({ model: 'model-a', provider: 'provider-a' }) expect(queryClient.getQueryData(ambientAKey)).toMatchObject({ model: 'model-a', provider: 'provider-a' })
expect(notifyError).toHaveBeenCalled() 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')
})
}) })

View File

@@ -7,7 +7,7 @@ import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { isBusySessionModelSwitch } from '@/lib/gateway-rpc' import { isBusySessionModelSwitch } from '@/lib/gateway-rpc'
import { surfaceModelSwitchConfirm } from '@/lib/guarded-model-switch' 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 { notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile' import { $activeGatewayProfile } from '@/store/profile'
import { import {
@@ -110,60 +110,97 @@ export function useModelControls({
// only fills an EMPTY selection so a user's pick (plain UI state in // only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh // $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely. // draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => { const refreshCurrentModel = useCallback(
// A forced profile swap opens a new intent epoch; an older in-flight async (force = false) => {
// response for a previous profile must stand down when it resolves. // A forced profile swap opens a new intent epoch; an older in-flight
if (force) { // response for a previous profile must stand down when it resolves.
profileRefreshEpochRef.current += 1 if (force) {
} profileRefreshEpochRef.current += 1
const profileRefreshEpoch = profileRefreshEpochRef.current
const profile = $activeGatewayProfile.get()
try {
if ($activeSessionId.get()) {
return
} }
// A manual pick is sticky. It is never diffed against the catalog: rows const profileRefreshEpoch = profileRefreshEpochRef.current
// are hints, and a custom slug the row lacks is still the user's choice const profile = $activeGatewayProfile.get()
// (the gateway validates it on switch).
const keepManualPick = () => !force && Boolean($currentModel.get()) && getCurrentModelSource() === 'manual'
if (keepManualPick()) { try {
return 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<ModelOptionsResult>(catalogKey) ??
(await queryClient.fetchQuery({
queryKey: catalogKey,
queryFn: (): Promise<ModelOptionsResult> =>
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 [cacheOwnerConnectionId, cacheProfile, queryClient, requestGateway]
// 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.
}
}, [])
// Drop a sticky composer pick so new chats follow Settings → Model again, // Drop a sticky composer pick so new chats follow Settings → Model again,
// without making the user re-apply the default they already have (#107410). // without making the user re-apply the default they already have (#107410).

View File

@@ -286,3 +286,86 @@ describe('in-flight local downloads', () => {
expect(screen.queryByText('Local')).toBeNull() 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()
})
})

View File

@@ -445,6 +445,85 @@ export function ModelCatalogMenu({
closeMenu() 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 | { key: string; trigger: HTMLElement }>(null)
const openActiveSubmenu = (): boolean => {
const trigger = listRef.current?.querySelector<HTMLElement>('[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. // Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
@@ -479,6 +558,13 @@ export function ModelCatalogMenu({
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
commitKbRow() 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 => { onValueChange={value => {
@@ -589,7 +675,10 @@ export function ModelCatalogMenu({
// Clicking the row commits the model and closes; the edit // Clicking the row commits the model and closes; the edit
// submenu (reasoning/fast) is reached by HOVER, so you can // 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 = () => { const activate = () => {
if (!isCurrent) { if (!isCurrent) {
void selectFamily(family, group.provider) void selectFamily(family, group.provider)
@@ -599,9 +688,11 @@ export function ModelCatalogMenu({
} }
return ( return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}> <DropdownMenuSub
key={`${group.provider.slug}:${family.id}`}
onOpenChange={open => handleSubOpenChange(open, `${group.provider.slug}:${family.id}`)}
>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
hideChevron
onClick={activate} onClick={activate}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') { 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. */ /** Re-exported so callers building a footer row match the catalog's rows. */
export { dropdownMenuRow } 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 // The backend's provider row for staged local models (inventory.py's
// _local_runtime_row). Downloads-in-flight attach to this group. // _local_runtime_row). Downloads-in-flight attach to this group.
const LOCAL_PROVIDER_SLUG = 'llamacpp' const LOCAL_PROVIDER_SLUG = 'llamacpp'

View File

@@ -151,7 +151,7 @@ describe('ModelMenuPanel current selection', () => {
const { content } = renderPanel() const { content } = renderPanel()
const currentRow = (await content.findByText(/Gemini 3\.1 Pro/i)).closest('[role="menuitem"]') 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(currentRow?.querySelector('.codicon-check')).not.toBeNull()
expect(staleRow?.querySelector('.codicon-check')).toBeNull() expect(staleRow?.querySelector('.codicon-check')).toBeNull()
@@ -178,7 +178,7 @@ describe('ModelMenuPanel search', () => {
$currentModel.set('deepseek-v4-pro') $currentModel.set('deepseek-v4-pro')
const { content } = renderPanel() 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' }) const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini' } }) fireEvent.change(input, { target: { value: 'gemini' } })
@@ -186,7 +186,7 @@ describe('ModelMenuPanel search', () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull() 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 () => { 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') const header = await content.findByText('DeepSeek')
// Collapse // Collapse
fireEvent.click(header) fireEvent.click(header)
expect(content.queryByText('Deepseek V4 Pro')).toBeNull() expect(content.queryByText('DeepSeek V4 Pro')).toBeNull()
// Expand // Expand
fireEvent.click(header) fireEvent.click(header)
await vi.waitFor(() => { 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 // The current provider is collapsible like any other — clicking its header
// hides its models rather than forcing them to stay open. // hides its models rather than forcing them to stay open.
await vi.waitFor(() => { 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') const header = await content.findByText('DeepSeek')
fireEvent.click(header) 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) // Type in the search bar (auto-focused by DropdownMenuSearch)
const input = screen.getByRole('textbox', { name: 'Search models' }) const input = screen.getByRole('textbox', { name: 'Search models' })
@@ -396,7 +396,7 @@ describe('ModelMenuPanel provider collapse', () => {
(_, element) => (_, element) =>
element?.tagName === 'SPAN' && element?.tagName === 'SPAN' &&
!element.querySelector('span') && !element.querySelector('span') &&
(element.textContent ?? '').startsWith('Deepseek V4 Pro') (element.textContent ?? '').startsWith('DeepSeek V4 Pro')
) )
).not.toBeNull() ).not.toBeNull()
}) })
@@ -409,7 +409,7 @@ describe('ModelMenuPanel provider collapse', () => {
// Radix DropdownMenuItem fires onSelect on Enter from the onKeyDown handler // Radix DropdownMenuItem fires onSelect on Enter from the onKeyDown handler
fireEvent.keyDown(header.closest('[role="menuitem"]') ?? header, { key: 'Enter' }) 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 // The collapsed-providers set is a global presentation preference
@@ -482,7 +482,7 @@ describe('ModelMenuPanel provider collapse', () => {
const { content, onSelectModel } = renderPanel() 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')) fireEvent.click(await content.findByText('Refresh models'))
@@ -512,7 +512,7 @@ describe('ModelMenuPanel provider collapse', () => {
const { content, onSelectModel } = renderPanel() 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')) fireEvent.click(await content.findByText('Refresh models'))
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -536,7 +536,7 @@ describe('ModelMenuPanel provider collapse', () => {
const { content, onSelectModel } = renderPanel() 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"]')))] const items = [...new Set(rows.map(row => row.closest('[role="menuitem"]')))]
expect(items).toHaveLength(2) expect(items).toHaveLength(2)

View File

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelOptions } from '@/hermes' 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: [] } const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] }
@@ -222,3 +222,38 @@ describe('catalogProviderMatches', () => {
expect(catalogProviderMatches(cloudflare, 'openrouter')).toBe(false) 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)
})
})

View File

@@ -36,6 +36,34 @@ export function currentModelCapabilities(
// `deepseek-v4.1-flash` for the row's `-0731` sibling. The only authority on a // `deepseek-v4.1-flash` for the row's `-0731` sibling. The only authority on a
// pick's validity is the gateway's switch result. // 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 { interface ModelOptionsRequest {
/** When false, include ambient/unconfigured providers (onboarding/setup /** When false, include ambient/unconfigured providers (onboarding/setup
* surfaces). Chat pickers default to true so only explicitly configured * surfaces). Chat pickers default to true so only explicitly configured

View File

@@ -27,13 +27,51 @@ describe('model-status-label', () => {
expect(modelDisplayParts('anthropic/claude-opus-4.8-fast').tag).toBe('Fast') 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', () => { 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('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('openai/gpt-5.5')).toBe('GPT-5.5')
expect(formatModelPillLabel('')).toBe('No model') 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', () => { describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' } const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' } const options = { model: 'hermes-4', provider: 'nous' }

View File

@@ -72,6 +72,40 @@ const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim() 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<readonly [RegExp, string]> = [
[/\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 { function prettifyBase(base: string): string {
if (/^deepseek-flash$/i.test(base)) { if (/^deepseek-flash$/i.test(base)) {
return 'DeepSeek V4.1 Flash' return 'DeepSeek V4.1 Flash'
@@ -80,11 +114,13 @@ function prettifyBase(base: string): string {
if (/^claude-/i.test(base)) { if (/^claude-/i.test(base)) {
// Anthropic ids spell the version with hyphens (`haiku-4-5`, `fable-5-1`); // 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". // the human name is dotted ("Haiku 4.5"), not "Haiku 4 5".
return titleCase( return applyVendorCasing(
base titleCase(
.replace(/^claude-/i, '') base
.replace(/(\d)-(?=\d)/g, '$1.') .replace(/^claude-/i, '')
.replace(/-/g, ' ') .replace(/(\d)-(?=\d)/g, '$1.')
.replace(/-/g, ' ')
)
) )
} }
@@ -92,11 +128,13 @@ function prettifyBase(base: string): string {
return base.replace(/^gpt-/i, 'GPT-') 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)) { 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 /** 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 } 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 { 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 /** 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 * 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. */ * long model name can no longer push the effort out of the truncating span. */
export function formatModelPillLabel(model: string, options?: { fastMode?: boolean }): string { 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 // 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). // active model is a `…-fast` variant (fast via a separate model id). The
if (model.trim() && (options?.fastMode || /-fast$/i.test(modelBaseId(model)))) { // variant's tag already reads Fast in the label above, so only the
return `${name} · Fast` // 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
} }

View File

@@ -106,6 +106,30 @@ def _like_params(term: str) -> List[str]:
return [f"%{_escape_like(term)}%"] * 3 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: def _flatten_text(decoded: Any) -> str:
"""Multimodal part list -> joined text (or the placeholder); str passes through; else ''.""" """Multimodal part list -> joined text (or the placeholder); str passes through; else ''."""
if isinstance(decoded, list): 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 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 (>=3 CJK chars per token); then a LIKE substring scan with one clause per
non-operator token so "广西 OR 桂林 OR 漓江" matches each term.""" 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) 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): if self._fts_cjk_available and not wants_unindexed_rows and not self._has_lone_cjk_run(raw_query):
matches = self._match_rows( matches = self._match_rows(

View File

@@ -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()