@lynn123411/dsh-model-submenu 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,39 @@
1
- import { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
- IconCheckOutlineRegular,
5
4
  IconChevronDownOutlineRegular,
6
- IconChevronLeftOutlineRegular,
7
- IconChevronRightOutlineRegular,
8
- IconDataOutlineRegular,
5
+ IconFlatListOutlineRegular,
9
6
  IconWarningOutlineRegular,
10
7
  MenuSurface,
11
8
  Toast,
12
9
  } from '@deepseek-ai/dsh-client-ui-primitives'
13
10
  import type { ModelDirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
14
11
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
12
+ import { ModelPicker } from './ModelPicker.tsx'
13
+ import { ModelRow } from './ModelRow.tsx'
14
+ import type { FactsSource } from './facts-source.ts'
15
+ import {
16
+ isFavorite,
17
+ orderWithFavorites,
18
+ readFavorites,
19
+ toggleFavorite,
20
+ type FavoriteModel,
21
+ type FavoritesStorage,
22
+ } from './favorites.ts'
23
+ import { effortFor, readEfforts, rememberEffort, type RememberedEffort } from './effort-memory.ts'
24
+ import type { EffortChoice, Selection } from './selection.ts'
25
+ import { reasoningOf } from './rows.ts'
26
+ import { effortChoices, planEffortCommit, planModelCommit } from './selection.ts'
15
27
  import css from './ModelSubmenu.module.css'
16
28
 
17
- type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'>
29
+ type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'> & {
30
+ /** The live settings-derived metadata the picker's line reads. */
31
+ readonly factsSource: FactsSource
32
+ }
18
33
 
19
- type Selection = Parameters<ModelSelectInjected['select']>[0]
20
34
  type ProviderGroup = ModelDirectoryState['groups'][number]
21
35
  type CatalogModel = ProviderGroup['models'][number]
22
36
 
23
- /** Which list the open menu is showing. */
24
- type Pane = 'root' | 'providers' | 'models' | 'effort'
25
-
26
37
  /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
27
38
  const MEASURE_STYLE = {
28
39
  visibility: 'hidden',
@@ -34,27 +45,26 @@ const cls = (...parts: readonly (string | false | undefined)[]): string => parts
34
45
 
35
46
  /**
36
47
  * The composer's model seat (`conversation.input.model`), taken over from the
37
- * built-in occupant with a provider level: the root pane keeps the built-in's
38
- * two cells — 供应商 in place of 模型, plus the native 推理等级 — the provider
39
- * cell drills into the catalog's providers, and a provider drills into its
40
- * models before submitting. The trigger, the menu surface, the keyboard
41
- * contract (arrows move, Tab settles, Escape and Shift+Tab leave a drilled pane
42
- * first), the effort list and the failure surfaces are the built-in ones.
48
+ * built-in occupant with a provider-scoped menu: one provider's models above,
49
+ * the reasoning row as segments, the cache warning, and the door to the picker
50
+ * that lists every provider.
51
+ *
52
+ * Every click commits at once: a model, a reasoning level, or the picker door.
53
+ * Closing the menu never writes anything, and the picker reads the level the
54
+ * Host already accepted, so its "current" reading is never stale.
43
55
  */
44
- export function ModelSubmenu({ locked, available, directory, load, select, t }: ModelSubmenuProps) {
56
+ export function ModelSubmenu({ locked, available, directory, load, select, t, factsSource }: ModelSubmenuProps) {
45
57
  const state = useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot())
46
58
  const [open, setOpen] = useState(false)
47
- const [pane, setPane] = useState<Pane>('root')
48
- const [providerId, setProviderId] = useState<string | null>(null)
49
- const lastActionRef = useRef<'load' | 'select'>('load')
59
+ const [pickerOpen, setPickerOpen] = useState(false)
60
+ const [favorites, setFavorites] = useState<readonly FavoriteModel[]>(() => readFavorites(storageOf()))
61
+ const [memory, setMemory] = useState<readonly RememberedEffort[]>(() => readEfforts(storageOf()))
50
62
  const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
51
63
  const toastSeq = useRef(0)
52
64
  const rootRef = useRef<HTMLDivElement | null>(null)
53
65
  const triggerRef = useRef<HTMLButtonElement | null>(null)
54
66
  const menuRef = useRef<HTMLDivElement | null>(null)
55
67
  const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
56
- const itemRefs = useRef(new Map<string, HTMLButtonElement>())
57
- const focusIntent = useRef<string | null>(null)
58
68
  const id = useId()
59
69
 
60
70
  const groups = state.groups
@@ -63,52 +73,76 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
63
73
  const currentModel = currentGroup?.models.find((model) => model.id === current?.model)
64
74
  const reasoning = currentModel?.reasoning
65
75
  const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
66
- const effortLabel = reasoning === undefined
67
- ? undefined
68
- : effectiveEffort === undefined
69
- ? t('effort.providerDefault')
70
- : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort
71
- const effortChoices = useMemo(() => reasoning === undefined ? [] : [
72
- ...reasoning.defaultEffort === undefined
73
- ? [{ key: 'provider-default', effort: undefined as string | undefined, label: t('effort.providerDefault') }]
74
- : [],
75
- ...reasoning.efforts.map((level) => ({ key: `effort:${level.id}`, effort: level.id as string | undefined, label: level.name })),
76
- ], [reasoning, t])
77
-
78
- const shownGroup = pane === 'models' && providerId !== null ? groups.find((group) => group.id === providerId) : undefined
76
+ /**
77
+ * The trigger's caption. A model that advertises no levels still says so in
78
+ * the same place: the blue caption beside the name reads 默认 rather than
79
+ * disappearing, so the shape of the control never changes with the model.
80
+ */
81
+ const effortLabel = reasoning === undefined || effectiveEffort === undefined
82
+ ? t('effort.providerDefault')
83
+ : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort
84
+ const choices = useMemo(() => effortChoices(reasoning, t('effort.providerDefault')), [reasoning, t])
79
85
  const busy = state.status === 'selecting'
86
+ const live = useMemo(() => selectionOf(state), [state])
87
+ const reason = useMemo(() => reasoningOf(groups), [groups])
88
+ const facts = useSyncExternalStore((fn) => factsSource.subscribe(fn), () => factsSource.getSnapshot())
89
+
90
+ const menuRows = useMemo(() => currentGroup === undefined ? [] : orderWithFavorites(
91
+ currentGroup.models,
92
+ (model) => isFavorite(favorites, currentGroup.id, model.id),
93
+ ), [currentGroup, favorites])
80
94
 
95
+ const raiseToast = (text: string) => {
96
+ toastSeq.current += 1
97
+ setToast({ seq: toastSeq.current, text })
98
+ }
81
99
  const reload = () => {
82
- lastActionRef.current = 'load'
83
100
  load()
84
101
  }
102
+ const submit = (selection: Selection, onDone: (() => void) | null, onRejected?: () => void) => {
103
+ select(selection).then((result) => {
104
+ if (result === undefined || result.ok) {
105
+ // The Host accepted it, so this is now the level this model uses.
106
+ remember(selection.provider, selection.model, selection.reasoningEffort)
107
+ onDone?.()
108
+ return
109
+ }
110
+ onRejected?.()
111
+ raiseToast(result.error.code === 'session/writer-held'
112
+ ? t('error.sessionInUse')
113
+ : t('error.action', { message: `${result.error.code}: ${result.error.message}` }))
114
+ })
115
+ }
116
+ /** Close the menu. Closing writes nothing: every choice already committed on click. */
117
+ const closeMenu = useCallback((restoreFocus: boolean) => {
118
+ setOpen(false)
119
+ if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
120
+ }, [])
85
121
 
86
122
  useEffect(() => {
87
123
  if (!open) return
88
124
  const closeOutside = (event: MouseEvent) => {
89
125
  if (rootRef.current?.contains(event.target as Node) === true) return
90
126
  if (menuRef.current?.contains(event.target as Node) === true) return
91
- setOpen(false)
92
- setPane('root')
127
+ closeMenu(false)
93
128
  }
94
129
  document.addEventListener('mousedown', closeOutside)
95
130
  return () => {
96
131
  document.removeEventListener('mousedown', closeOutside)
97
132
  }
98
- }, [open])
133
+ }, [open, closeMenu])
99
134
 
100
135
  useEffect(() => {
101
- const intent = focusIntent.current
102
- focusIntent.current = null
103
- if (!open || intent === null) return
104
- if (intent === 'initial') {
105
- const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled]), [role="menuitem"][aria-current="true"]:not([disabled])')
106
- const first = [...itemRefs.current].find(([key]) => !key.startsWith('back:'))?.[1]
107
- ;(checked ?? first ?? triggerRef.current)?.focus()
108
- return
109
- }
110
- ;(itemRefs.current.get(intent) ?? triggerRef.current)?.focus()
111
- }, [open, pane])
136
+ if (!open) return
137
+ const frame = requestAnimationFrame(() => {
138
+ const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
139
+ const first = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"]:not([disabled])')
140
+ const target = checked ?? first ?? triggerRef.current
141
+ target?.scrollIntoView({ block: 'nearest' })
142
+ target?.focus()
143
+ })
144
+ return () => { cancelAnimationFrame(frame) }
145
+ }, [open])
112
146
 
113
147
  useLayoutEffect(() => {
114
148
  if (!open) {
@@ -121,6 +155,7 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
121
155
  const MARGIN = 12
122
156
  const lw = menuRef.current?.offsetWidth ?? 0
123
157
  const lh = menuRef.current?.offsetHeight ?? 0
158
+ // Right edges align, so the card grows leftward from the trigger's corner.
124
159
  let x = rect.right - lw
125
160
  let y = rect.top - 8 - lh
126
161
  if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
@@ -134,61 +169,71 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
134
169
  window.removeEventListener('scroll', place, true)
135
170
  window.removeEventListener('resize', place)
136
171
  }
137
- }, [open, pane, state])
172
+ }, [open, state])
138
173
 
139
174
  if (!available) return null
140
175
 
141
176
  const show = () => {
142
- setPane('root')
143
- setProviderId(null)
144
177
  setOpen(true)
145
178
  reload()
146
179
  }
147
- const close = (restoreFocus = false) => {
180
+ /** The picker door: the level already landed on click, so only the surface changes. */
181
+ const openPicker = () => {
148
182
  setOpen(false)
149
- setPane('root')
150
- setProviderId(null)
151
- if (restoreFocus) queueMicrotask(() => {
152
- triggerRef.current?.focus()
153
- })
183
+ setPickerOpen(true)
154
184
  }
155
- const drill = (next: Pane) => {
156
- focusIntent.current = 'initial'
157
- setPane(next)
185
+ /** Record one model's level, so switching away and back restores it. */
186
+ const remember = (provider: string, model: string, effort: string | undefined) => {
187
+ setMemory(rememberEffort(storageOf(), provider, model, effort))
158
188
  }
159
- /** Leave the shown pane for its parent, handing the keyboard back to the row that opened it. */
160
- const back = () => {
161
- if (pane === 'models') {
162
- focusIntent.current = providerId === null ? null : `provider:${providerId}`
163
- setPane('providers')
189
+ const chooseModel = (provider: string, model: CatalogModel) => {
190
+ const picked = planModelCommit(live, provider, model.id, reason, effortFor(memory, provider, model.id))
191
+ if (picked === null) {
192
+ setOpen(false)
193
+ queueMicrotask(() => { triggerRef.current?.focus() })
164
194
  return
165
195
  }
166
- focusIntent.current = pane === 'effort' ? 'root:effort' : 'root:provider'
167
- setPane('root')
196
+ // A model click is the decision: close at once, and keep the menu open when
197
+ // the host rejects it so the user can try another model.
198
+ setOpen(false)
199
+ submit(picked, () => { queueMicrotask(() => { triggerRef.current?.focus() }) }, () => {
200
+ // The host refused it: put the menu back so the user can pick another.
201
+ setOpen(true)
202
+ })
168
203
  }
204
+ const rowButtons = () => [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]:not([disabled])') ?? [])]
169
205
  const moveFocus = (offset: number) => {
170
- const items = [...itemRefs.current.values()]
206
+ const items = rowButtons()
171
207
  if (items.length === 0) return
172
208
  const active = items.findIndex((item) => item === document.activeElement)
173
209
  items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus()
174
210
  }
211
+ const onEffortKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
212
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
213
+ if (choices.length === 0 || live === null) return
214
+ event.preventDefault()
215
+ const index = choices.findIndex((choice) => choice.effort === effectiveEffort)
216
+ const step = event.key === 'ArrowRight' ? 1 : -1
217
+ const next = choices[((index === -1 ? 0 : index) + step + choices.length) % choices.length]
218
+ if (next === undefined) return
219
+ const pending = planEffortCommit(live, next.effort)
220
+ if (pending !== null) submit(pending, null)
221
+ }
175
222
  const onRootKeyDown = (event: React.KeyboardEvent) => {
176
223
  if (event.key === 'Escape' && open) {
177
224
  event.preventDefault()
178
- if (pane !== 'root') back()
179
- else close(true)
225
+ closeMenu(true)
180
226
  return
181
227
  }
182
228
  if (!open) return
183
229
  if (event.key === 'Tab') {
184
230
  if (event.shiftKey) {
185
231
  event.preventDefault()
186
- if (pane !== 'root') back()
187
- else close(true)
232
+ closeMenu(true)
188
233
  return
189
234
  }
190
235
  const focused = document.activeElement
191
- const rows = [...itemRefs.current.values()]
236
+ const rows = rowButtons()
192
237
  if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
193
238
  event.preventDefault()
194
239
  focused.click()
@@ -196,8 +241,8 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
196
241
  }
197
242
  if (focused !== triggerRef.current) return
198
243
  event.preventDefault()
199
- const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled]), [role="menuitem"][aria-current="true"]:not([disabled])')
200
- ;(checked ?? rows.find((item) => !item.disabled))?.focus()
244
+ const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
245
+ ;(checked ?? rows[0])?.focus()
201
246
  return
202
247
  }
203
248
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@@ -205,46 +250,24 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
205
250
  moveFocus(event.key === 'ArrowDown' ? 1 : -1)
206
251
  }
207
252
  }
253
+ /**
254
+ * The keyboard leaving the seat closes the menu — but that is not one of the
255
+ * four closing gestures that write a mark, so it closes without committing.
256
+ * Otherwise clicking the notice text or a divider inside the menu, which moves
257
+ * focus without unmounting it, would land a level nobody asked to commit.
258
+ */
208
259
  const onBlur = (event: React.FocusEvent) => {
209
- if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return
210
- close()
211
- }
212
- const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>) => {
213
- if (result === undefined) return
214
- if (result.ok) {
215
- if (rootRef.current !== null) close(true)
216
- return
217
- }
218
- const { error } = result
219
- toastSeq.current += 1
220
- setToast({
221
- seq: toastSeq.current,
222
- text: error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: `${error.code}: ${error.message}` }),
223
- })
224
- }
225
- const submit = (selection: Selection) => {
226
- lastActionRef.current = 'select'
227
- select(selection).then(settleSelection)
228
- }
229
- /** A model commits outright; its reasoning levels stay behind the root's effort cell. */
230
- const chooseModel = (provider: string, model: CatalogModel) => {
231
- if (current?.provider === provider && current.model === model.id) {
232
- close(true)
233
- return
234
- }
235
- submit({ provider, model: model.id })
260
+ if (pickerOpen) return
261
+ if (event.relatedTarget instanceof Node
262
+ && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return
263
+ setOpen(false)
236
264
  }
237
- const chooseEffort = (effort: string | undefined) => {
238
- if (current === null) return
239
- if (effectiveEffort === effort) {
240
- close(true)
241
- return
242
- }
243
- submit({ provider: current.provider, model: current.model, ...effort === undefined ? {} : { reasoningEffort: effort } })
265
+ const toggleFavoriteOf = (provider: string, model: string) => {
266
+ setFavorites(toggleFavorite(storageOf(), provider, model))
244
267
  }
268
+
245
269
  const waiting = current === null && state.status === 'loading'
246
270
  const modelLabel = waiting ? t('trigger.loading') : currentModel?.name ?? (current === null ? t('trigger.fallback') : `${current.provider}/${current.model}`)
247
- const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
248
271
  const triggerAria = waiting
249
272
  ? t('trigger.loading')
250
273
  : current === null
@@ -252,18 +275,12 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
252
275
  : effortLabel === undefined
253
276
  ? t('trigger.aria', { model: modelLabel })
254
277
  : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
278
+ const providerName = currentGroup?.name
279
+ ?? state.failures.find((failure) => failure.id === current?.provider)?.name
280
+ ?? current?.provider
281
+ ?? ''
282
+ const providerFailure = current === null ? undefined : state.failures.find((failure) => failure.id === current.provider)
255
283
 
256
- itemRefs.current = new Map()
257
- const itemRef = (key: string) => (node: HTMLButtonElement | null) => {
258
- if (node === null) itemRefs.current.delete(key)
259
- else itemRefs.current.set(key, node)
260
- }
261
- const backRow = () => (
262
- <button ref={itemRef('back')} type="button" role="menuitem" className={css.cell} onClick={back}>
263
- <IconChevronLeftOutlineRegular className={css.cellChevron} />
264
- <span className={css.cellLabel}>{t('back')}</span>
265
- </button>
266
- )
267
284
  return (
268
285
  <div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
269
286
  <button
@@ -274,14 +291,14 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
274
291
  aria-haspopup="menu"
275
292
  aria-expanded={open}
276
293
  aria-controls={open ? `${id}-menu` : undefined}
277
- title={triggerLabel}
294
+ title={effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`}
278
295
  disabled={locked}
279
296
  onClick={() => {
280
- if (open) close()
297
+ if (open) closeMenu(false)
281
298
  else show()
282
299
  }}
283
300
  >
284
- <IconDataOutlineRegular className={css.triggerIcon} size={16} />
301
+ <IconFlatListOutlineRegular className={css.triggerIcon} size={16} />
285
302
  <span className={css.triggerLabel}>{modelLabel}</span>
286
303
  {effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
287
304
  <IconChevronDownOutlineRegular className={cls(css.chevron, open && css.chevronOpen)} />
@@ -292,125 +309,95 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
292
309
  id={`${id}-menu`}
293
310
  className={css.menu}
294
311
  style={menuPos ?? MEASURE_STYLE}
295
- role="menu"
296
- aria-label={pane === 'models' && shownGroup !== undefined ? shownGroup.name : pane === 'effort' ? t('menu.effort') : t('menu.aria')}
312
+ role="group"
313
+ aria-label={t('menu.aria')}
297
314
  aria-busy={state.status === 'loading' || busy}
298
315
  >
299
- {pane === 'root' && (
300
- <Fragment>
301
- <button ref={itemRef('root:provider')} type="button" role="menuitem" className={css.cell} onClick={() => drill('providers')}>
302
- <span className={css.cellLabel}>{t('menu.provider')}</span>
303
- <span className={css.cellValue}>{currentGroup?.name ?? current?.provider ?? ''}</span>
304
- <IconChevronRightOutlineRegular className={css.cellChevron} />
305
- </button>
306
- {reasoning !== undefined && (
307
- <button ref={itemRef('root:effort')} type="button" role="menuitem" className={css.cell} onClick={() => drill('effort')}>
308
- <span className={css.cellLabel}>{t('menu.effort')}</span>
309
- <span className={css.cellValue}>{effortLabel}</span>
310
- <IconChevronRightOutlineRegular className={css.cellChevron} />
311
- </button>
312
- )}
313
- </Fragment>
316
+ <div className={css.providerName} title={providerName}>{providerName}</div>
317
+ {state.status === 'loading' && <div className={css.notice}>{t('status.loading')}</div>}
318
+ {state.error !== null && state.status === 'error' && (
319
+ <div className={css.notice}>
320
+ <span>{t('status.failed', { message: state.error })}</span>
321
+ <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
322
+ </div>
314
323
  )}
315
- {pane === 'providers' && (
316
- <Fragment>
317
- {state.status === 'loading' && <div className={css.status}>{t('status.loading')}</div>}
318
- {state.error !== null && lastActionRef.current === 'load' && (
319
- <div className={css.error}>
320
- <span>{t('error.action', { message: state.error })}</span>
321
- <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
322
- </div>
323
- )}
324
- {state.failures.map((failure) => (
325
- <div className={css.warning} key={failure.id}>
326
- <span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
327
- <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
328
- </div>
329
- ))}
330
- {state.status === 'ready' && groups.length === 0 && <div className={css.empty}>{t('empty.models')}</div>}
331
- <div className={cls(css.groups, 'scrollable')}>
332
- {groups.map((group) => {
333
- const mine = current?.provider === group.id
334
- return (
335
- <button
336
- key={group.id}
337
- ref={itemRef(`provider:${group.id}`)}
338
- type="button"
339
- role="menuitem"
340
- aria-current={mine ? 'true' : undefined}
341
- className={css.cell}
342
- title={group.name}
343
- onClick={() => {
344
- setProviderId(group.id)
345
- drill('models')
346
- }}
347
- >
348
- <span className={css.cellLabel}>{group.name}</span>
349
- <span className={css.cellValue}>{mine ? currentModel?.name ?? current?.model ?? '' : t('provider.modelCount', { count: String(group.models.length) })}</span>
350
- {mine && <span className={css.check}><IconCheckOutlineRegular /></span>}
351
- <IconChevronRightOutlineRegular className={css.cellChevron} />
352
- </button>
353
- )
354
- })}
355
- </div>
356
- </Fragment>
324
+ {providerFailure !== undefined && (
325
+ <div className={css.notice}>
326
+ <span>{t('status.providerFailed', { name: providerFailure.name, message: providerFailure.message })}</span>
327
+ <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
328
+ </div>
357
329
  )}
358
- {pane === 'models' && shownGroup !== undefined && (
359
- <Fragment>
360
- {backRow()}
361
- <div className={cls(css.groups, 'scrollable')}>
362
- {shownGroup.models.map((model) => {
363
- const selected = current?.provider === shownGroup.id && current.model === model.id
364
- return (
330
+ <div className={cls(css.groups, 'scrollable')} role="menu" aria-label={t('menu.aria')}>
331
+ {menuRows.map((model) => (
332
+ <ModelRow
333
+ key={model.id}
334
+ name={model.name}
335
+ selected={current !== null && currentGroup !== undefined && current.provider === currentGroup.id && current.model === model.id}
336
+ busy={busy}
337
+ star="static"
338
+ favorited={currentGroup !== undefined && isFavorite(favorites, currentGroup.id, model.id)}
339
+ onPick={() => {
340
+ if (currentGroup !== undefined) chooseModel(currentGroup.id, model)
341
+ }}
342
+ />
343
+ ))}
344
+ {menuRows.length === 0 && state.status === 'ready' && (
345
+ <div className={css.notice}>{t('status.empty')}</div>
346
+ )}
347
+ </div>
348
+ {choices.length > 0 && (
349
+ <>
350
+ <div className={css.divider} />
351
+ <div className={css.effortRow}>
352
+ <span className={css.effortHeading}>{t('menu.effort')}</span>
353
+ <div className={css.effortTrack} role="radiogroup" aria-label={t('menu.effort')} onKeyDown={onEffortKeyDown}>
354
+ {choices.map((choice: EffortChoice) => (
365
355
  <button
366
- key={model.id}
367
- ref={itemRef(`model:${shownGroup.id}/${model.id}`)}
356
+ key={choice.key}
368
357
  type="button"
369
- role="menuitemradio"
370
- aria-checked={selected}
371
- className={cls(css.option, selected && css.selected)}
372
- title={model.name}
358
+ role="radio"
359
+ aria-checked={choice.effort === effectiveEffort}
360
+ className={css.effortSegment}
373
361
  disabled={busy}
374
- onClick={() => chooseModel(shownGroup.id, model)}
362
+ onClick={() => {
363
+ if (live !== null) {
364
+ const pending = planEffortCommit(live, choice.effort)
365
+ if (pending !== null) submit(pending, null)
366
+ }
367
+ }}
375
368
  >
376
- <span className={css.optionCopy}><span className={css.modelName}>{model.name}</span></span>
377
- <span className={css.check}>{selected ? <IconCheckOutlineRegular /> : null}</span>
369
+ {choice.label}
378
370
  </button>
379
- )
380
- })}
381
- </div>
382
- </Fragment>
383
- )}
384
- {pane === 'effort' && (
385
- <Fragment>
386
- {backRow()}
387
- {state.error !== null && lastActionRef.current === 'load' && (
388
- <div className={css.error}>
389
- <span>{t('error.action', { message: state.error })}</span>
390
- <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
371
+ ))}
391
372
  </div>
392
- )}
393
- {effortChoices.length === 0
394
- ? <div className={css.empty}>{t('empty.efforts')}</div>
395
- : effortChoices.map((level) => (
396
- <button
397
- key={level.key}
398
- ref={itemRef(`effort:${level.key}`)}
399
- type="button"
400
- role="menuitemradio"
401
- aria-checked={effectiveEffort === level.effort}
402
- className={cls(css.option, effectiveEffort === level.effort && css.selected)}
403
- disabled={busy}
404
- onClick={() => chooseEffort(level.effort)}
405
- >
406
- <span className={css.optionCopy}><span className={css.modelName}>{level.label}</span></span>
407
- <span className={css.check}>{effectiveEffort === level.effort ? <IconCheckOutlineRegular /> : null}</span>
408
- </button>
409
- ))}
410
- </Fragment>
373
+ </div>
374
+ <div className={css.divider} />
375
+ </>
411
376
  )}
377
+ <p className={css.hint}>{t('menu.hint')}</p>
378
+ <div className={css.divider} />
379
+ <button type="button" role="menuitem" className={css.moreRow} onClick={openPicker}>
380
+ <IconFlatListOutlineRegular className={css.moreIcon} size={16} />
381
+ <span>{t('menu.more')}</span>
382
+ </button>
412
383
  </MenuSurface>
413
384
  ), document.body)}
385
+ <ModelPicker
386
+ open={pickerOpen}
387
+ state={state}
388
+ live={live}
389
+ facts={facts}
390
+ favorites={favorites}
391
+ busy={busy}
392
+ t={t}
393
+ onPick={(selection) => { submit(selection, () => { setPickerOpen(false) }) }}
394
+ onToggleFavorite={toggleFavoriteOf}
395
+ onReload={reload}
396
+ onClose={() => {
397
+ setPickerOpen(false)
398
+ queueMicrotask(() => { triggerRef.current?.focus() })
399
+ }}
400
+ />
414
401
  {toast !== null && (
415
402
  <Toast
416
403
  key={toast.seq}
@@ -424,4 +411,21 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
424
411
  )}
425
412
  </div>
426
413
  )
427
- }
414
+ }
415
+
416
+ /** The selection in force, or null while the host has not resolved one. */
417
+ function selectionOf(state: ModelDirectoryState): Selection | null {
418
+ if (state.current === null) return null
419
+ return state.current.reasoningEffort === undefined
420
+ ? { provider: state.current.provider, model: state.current.model }
421
+ : { provider: state.current.provider, model: state.current.model, reasoningEffort: state.current.reasoningEffort }
422
+ }
423
+
424
+ /** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
425
+ function storageOf(): FavoritesStorage | undefined {
426
+ try {
427
+ return globalThis.localStorage
428
+ } catch {
429
+ return undefined
430
+ }
431
+ }