Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -7959,6 +7959,10 @@
"en": "Max",
"zh": "全力"
},
"gui.picker.title": {
"en": "Model",
"zh": "模型"
},
"gui.picker.recent": {
"en": "Recent",
"zh": "最近使用"
Expand Down
2 changes: 2 additions & 0 deletions ui-tui/src/i18n/messages.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2206,6 +2206,7 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'gui.tier.medium': 'Light',
'gui.tier.high': 'Standard',
'gui.tier.max': 'Max',
'gui.picker.title': 'Model',
'gui.picker.recent': 'Recent',
'gui.picker.show_all': 'Show all {n}',
'gui.wd.open_remote': "Browse the host's folders...",
Expand Down Expand Up @@ -4569,6 +4570,7 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'gui.tier.medium': '轻',
'gui.tier.high': '标准',
'gui.tier.max': '全力',
'gui.picker.title': '模型',
'gui.picker.recent': '最近使用',
'gui.picker.show_all': '显示全部 {n} 个',
'gui.wd.open_remote': '浏览主机上的文件夹…',
Expand Down
33 changes: 32 additions & 1 deletion ui-web/src/chrome/ModelChip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* de-dups by, because a provider's list mixes ids added by hand with ids the
* vendor reports.
*/
import { cleanup, render } from '@testing-library/react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import * as store from '../features/model/store'
Expand Down Expand Up @@ -111,3 +111,34 @@ describe('the model chip', () => {
expect(name()).toBe('My Model')
})
})

describe('the model chip, to a screen reader', () => {
it('says whether the picker it opens is up', () => {
install([{ id: 'openrouter', name: 'OpenRouter', on: true, models: ['my-model'], configured: ['my-model'] }])
store.setCurrent('my-model')
mount()
const chip = (): HTMLElement => document.getElementById('modelChip')!
expect(chip().getAttribute('aria-expanded')).toBe('false')
act(() => store.open(chip()))
expect(chip().getAttribute('aria-expanded')).toBe('true')
act(() => store.close())
expect(chip().getAttribute('aria-expanded')).toBe('false')
})

it('says nothing about a picker another control opened', () => {
/* One store serves every opener, so "a picker is up" is not the same
question as "mine is up": the settings role pills open it against their
own button (features/settings/providers/Roles.tsx). A chip that answered
the first would hand a reader a control and a state that do not belong
together. */
install([{ id: 'openrouter', name: 'OpenRouter', on: true, models: ['my-model'], configured: ['my-model'] }])
store.setCurrent('my-model')
mount()
const chip = (): HTMLElement => document.getElementById('modelChip')!
const pill = document.body.appendChild(document.createElement('button'))

act(() => store.open(pill, undefined, undefined, { kind: 'text', title: 'Planner model' }))

expect(chip().getAttribute('aria-expanded')).toBe('false')
})
})
5 changes: 4 additions & 1 deletion ui-web/src/chrome/ModelChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import { useSyncExternalStore } from 'react'
import { useRef, useSyncExternalStore } from 'react'

import { Icon } from '../components/Icon'
import { ProviderIcon } from '../components/ProviderMark'
Expand Down Expand Up @@ -63,15 +63,18 @@ function serving(current: string): { provider: Provider; id: string } | null {
export function ModelChip(): JSX.Element {
useSyncExternalStore(model.subscribe, model.version)
useSyncExternalStore(paint.subscribe, paint.get)
const me = useRef<HTMLButtonElement>(null)
const current = model.current()
const at = serving(current)
const label = at?.provider.labels?.[at.id]?.label || model.short(current)
return (
<button
ref={me}
className="chip model"
id="modelChip"
title={current}
aria-haspopup="true"
aria-expanded={model.openedFrom(me.current) ? 'true' : 'false'}
onClick={() => {
if (openModelsForMissingProvider()) return
model.open(null)
Expand Down
80 changes: 79 additions & 1 deletion ui-web/src/features/model/ModelPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as pageStore from '../../state/page'
import { resetSources, setSources } from '../../state/sources'
import * as tier from '../../state/tier'
import { domSnapshot } from '../../test/domSnapshot'
import { ModelApp } from './ModelPicker'
import { FOLD, ModelApp } from './ModelPicker'
import * as store from './store';

import type { ModelSource, Provider } from './types'
Expand Down Expand Up @@ -972,3 +972,81 @@ describe('the picker with nothing to offer', () => {
expect(h.toasts).toEqual(['gui.picker.no_account'])
})
})

/* What a reader who cannot see the list is told. The tick is a glyph and the
grouping is a heading, so both are shape alone until the markup states them. */
describe('the model picker, to a screen reader', () => {
it('names the popover after what is being chosen', () => {
install()
mount()
openIt()
expect(pick()!.getAttribute('aria-label')).toBe('gui.picker.title')
})

it('names it after the slot instead, when a slot opened it', () => {
install()
mount()
act(() => { store.open(undefined, undefined, undefined, { kind: 'text', title: 'Planner model' }) })
expect(pick()!.getAttribute('aria-label')).toBe('Planner model')
})

it('states the current model as the checked one of a set', () => {
store.setCurrent('claude-sonnet-5')
install()
mount()
openIt()
expect(rows('models').map((b) => b.getAttribute('role'))).toEqual(['radio', 'radio', 'radio', 'radio'])
expect(rows('models').map((b) => b.getAttribute('aria-checked'))).toEqual(['false', 'false', 'false', 'true'])
})

it('gives each account its own set, named after the account', () => {
install()
mount()
openIt()
/* Per account rather than one set for the picker: the same model can be
listed under Recent and under its account, and one set may hold one
checked member. An account with nothing to list owns no set. */
expect(groups().map((g) => g.getAttribute('role'))).toEqual(['radiogroup', 'radiogroup', null])
expect(groups().map((g) => g.getAttribute('aria-label'))).toEqual(['MiniMax (Global)', 'Anthropic', null])
})

it('leaves the tick out of the row\'s spoken name, now that the state is said', () => {
/* Chrome builds a radio's name from its contents, so a glyph left visible
to it is read out beside the checked state it duplicates. */
store.setCurrent('claude-sonnet-5')
install()
mount()
openIt()
const ticked = rows('models').find((b) => b.querySelector('.tick'))!
expect(ticked.querySelector('.tick')!.getAttribute('aria-hidden')).toBe('true')
})

it('leaves the show-all row out of the set it sits in', () => {
/* It reveals more of the account's radios rather than being one: a row that
answers to the set's state would be offered as a model to pick. Asserted
against its siblings rather than on its own, because it carries their
`row` class: on a picker that marked no row at all, "the show-all row is
not a radio" is true for the wrong reason. */
const many = Array.from({ length: FOLD + 2 }, (_, i) => `router/model-${String(i)}`)
install({}, [{ id: 'router', name: 'Router', on: true, models: many, configured: many }])
mount()
openIt()
const siblings = [...document.querySelectorAll<HTMLElement>('.mpick .models .model-group .row')]
expect(siblings.map((b) => b.getAttribute('role')))
.toEqual([...Array<string | null>(FOLD).fill('radio'), null])
expect(siblings.at(-1)).toBe(more())
expect(more()!.getAttribute('aria-checked')).toBeNull()
})

it('gives the recent group a set of its own', async () => {
install()
mount()
openIt()
await act(async () => { rows('models')[3]!.click() })
openIt()
const recent = document.querySelector<HTMLElement>('.mpick .model-recent')!
expect(recent.getAttribute('role')).toBe('radiogroup')
expect(recent.getAttribute('aria-label')).toBe('gui.picker.recent')
expect(recents().map((b) => b.getAttribute('aria-checked'))).toEqual(['true'])
})
})
32 changes: 27 additions & 5 deletions ui-web/src/features/model/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,17 @@ function Row({ p, m, current, owner, account }: { p: Provider; m: string; curren
did before the pick started naming one. */
const ticked = (!owner || owner.id === p.id) && sameModel(p, m, current)
return (
<button className="row" title={store.short(m)} onClick={() => void store.choose(m, p.id)}>
<button
className="row"
role="radio"
aria-checked={ticked ? 'true' : 'false'}
title={store.short(m)}
onClick={() => void store.choose(m, p.id)}
>
<span className="nm">{label(p, m)}</span>
<ModelTags facts={p.labels?.[m]} />
{account ? <span className="ct">{p.name}</span> : null}
{ticked ? <span className="tick">✓</span> : null}
{ticked ? <span className="tick" aria-hidden="true">✓</span> : null}
</button>
)
}
Expand Down Expand Up @@ -285,7 +291,14 @@ function Pick(): JSX.Element {
}

return (
<div className="mpick" role="dialog" ref={box}>
/* The popover says what is being chosen: the slot's name where one opened it,
the word for a model otherwise. Through `t` and not `lang.attr`, which the
chip popovers name themselves with -- that one withholds the text until a
reader picks a language, because the markup it writes over is served with
words already. Nothing here is served: the picker does not exist until it
is opened, so there is no served text to leave standing and a reader who
never picks a language would get no name at all. */
<div className="mpick" role="dialog" aria-label={at.offer.title || t('gui.picker.title')} ref={box}>
<ModelTagDefs />
<div className="find">
{at.offer.title ? <span className="model-slot">{at.offer.title}</span> : null}
Expand Down Expand Up @@ -318,7 +331,7 @@ function Pick(): JSX.Element {
</div>
<div className="models">
{recents.length ? (
<div className="model-group model-recent">
<div className="model-group model-recent" role="radiogroup" aria-label={t('gui.picker.recent')}>
<div className="model-group-hd"><span className="nm">{t('gui.picker.recent')}</span></div>
{recents.map(({ p, m }) => <Row key={`${p.id}/${m}`} p={p} m={m} current={current} owner={currentProvider} account />)}
</div>
Expand All @@ -330,8 +343,17 @@ function Pick(): JSX.Element {
on the way. */
if (q && !list.length) return null
const { rows, more } = shown(p, list)
/* A set per account rather than one for the picker: a model listed by
an account is also listed under Recent, and both are marked, so one
set for the whole list would hold two checked members. */
return (
<div className="model-group" key={p.id} data-provider={p.id}>
<div
className="model-group"
key={p.id}
data-provider={p.id}
role={rows.length ? 'radiogroup' : undefined}
aria-label={rows.length ? p.name : undefined}
>
<div className="model-group-hd">
<ProviderIcon id={p.id} name={p.name} />
<span className="nm">{p.name}</span>
Expand Down
9 changes: 9 additions & 0 deletions ui-web/src/features/model/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ const announce = (): void => {
export const openAt = (): OpenAt => at
export const version = (): number => epoch
export const isOpen = (): boolean => !!at.host

/* Whether the picker that is up is this element's own. One store serves every
opener -- the composer chip, and each settings role pill against its own
button (features/settings/providers/Roles.tsx) -- so `isOpen` answers "a
picker is up somewhere", which is not the question a control reporting its
own state is putting. A chip that answered the first would tell a reader
that it had expanded something while the popover belongs to another
control. */
export const openedFrom = (el: Element | null): boolean => !!el && at.host === el
export const current = (): string => selected

/** The account serving `current`, or '' while nothing has said which. */
Expand Down
Loading