@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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The model picker: the centered dialog opened from the menu's last row.
3
+ *
4
+ * It is the one place every provider is visible at once — a search field, a tab
5
+ * per provider with a model, and one flat list with each model's metadata line.
6
+ * Favorites lead the list and can be toggled from a row's star without
7
+ * selecting that row. A click commits at once; a rejected commit keeps the
8
+ * dialog open so the user can retry or pick another model.
9
+ */
10
+
11
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
12
+ import {
13
+ IconSearchOutlineRegular,
14
+ Input,
15
+ Modal,
16
+ ShortcutKeys,
17
+ rankByName,
18
+ } from '@deepseek-ai/dsh-client-ui-primitives'
19
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
20
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
21
+ import { ModelRow } from './ModelRow.tsx'
22
+ import { isFavorite, orderWithFavorites } from './favorites.ts'
23
+ import type { ModelFacts } from './model-facts.ts'
24
+ import { metaSegments } from './model-facts.ts'
25
+ import {
26
+ ALL_TAB,
27
+ flattenGroups,
28
+ pickerTabs,
29
+ reasoningOf,
30
+ rowsForTab,
31
+ searchRows,
32
+ type ModelRow as CatalogRow,
33
+ type PickerTab,
34
+ } from './rows.ts'
35
+ import type { ModelFactsLookup } from './facts-source.ts'
36
+ import type { Selection } from './selection.ts'
37
+ import { planModelCommit } from './selection.ts'
38
+ import css from './ModelSubmenu.module.css'
39
+
40
+ /** Props the seat hands the picker. */
41
+ export interface ModelPickerProps {
42
+ /** Whether the dialog is showing. */
43
+ readonly open: boolean
44
+ /** The shared directory snapshot. */
45
+ readonly state: ModelDirectoryState
46
+ /** The selection in force, null before a first selection resolves. */
47
+ readonly live: Selection | null
48
+ /** The metadata lookup for the current settings answer. */
49
+ readonly facts: ModelFactsLookup
50
+ /** The favorite table right now. */
51
+ readonly favorites: readonly { readonly provider: string; readonly model: string }[]
52
+ /** Whether a selection is in flight. */
53
+ readonly busy: boolean
54
+ /** Submit one selection. */
55
+ readonly onPick: (selection: Selection) => void
56
+ /** Toggle one model's favorite. */
57
+ readonly onToggleFavorite: (provider: string, model: string) => void
58
+ /** Reload the catalog after a failure. */
59
+ readonly onReload: () => void
60
+ /** Close the dialog, optionally returning focus. */
61
+ readonly onClose: () => void
62
+ }
63
+
64
+ /**
65
+ * Render the picker.
66
+ * @param props - the catalog, the live selection, and the picker's verbs.
67
+ * @returns the dialog; nothing while closed.
68
+ */
69
+ export function ModelPicker({
70
+ open, state, live, facts, favorites, busy, onPick, onToggleFavorite, onReload, onClose, t,
71
+ }: ModelPickerProps & PropsLocale<'modelSubmenu'>) {
72
+ const id = useId()
73
+ const searchRef = useRef<HTMLInputElement | null>(null)
74
+ const listRef = useRef<HTMLDivElement | null>(null)
75
+ const [query, setQuery] = useState('')
76
+ const [tab, setTab] = useState(ALL_TAB)
77
+ const [highlight, setHighlight] = useState(0)
78
+ const tabRefs = useRef(new Map<string, HTMLButtonElement>())
79
+
80
+ const tabs = useMemo(() => pickerTabs(state.groups, t('picker.all')), [state.groups, t])
81
+ const reason = useMemo(() => reasoningOf(state.groups), [state.groups])
82
+ const rows = useMemo(() => orderWithFavorites(
83
+ rowsForTab(flattenGroups(state.groups), tab),
84
+ (row) => isFavorite(favorites, row.provider, row.model),
85
+ ), [state.groups, tab, favorites])
86
+ // Search runs inside the favorite partition, never across it: a query must not
87
+ // pull an unstarred model above a starred one, because favorites lead the list
88
+ // whether or not a query is typed.
89
+ const visible = useMemo(() => orderWithFavorites(
90
+ searchRows(rows, query, rankByName),
91
+ (row) => isFavorite(favorites, row.provider, row.model),
92
+ ), [rows, query, favorites])
93
+
94
+ useEffect(() => {
95
+ if (!open) return
96
+ setQuery('')
97
+ setTab(ALL_TAB)
98
+ setHighlight(0)
99
+ if (listRef.current !== null) listRef.current.scrollTop = 0
100
+ const frame = requestAnimationFrame(() => { searchRef.current?.focus() })
101
+ return () => { cancelAnimationFrame(frame) }
102
+ }, [open])
103
+
104
+ useEffect(() => {
105
+ setHighlight((current) => visible.length === 0 ? 0 : Math.min(current, visible.length - 1))
106
+ }, [visible.length])
107
+
108
+ useEffect(() => {
109
+ const row = listRef.current?.querySelector<HTMLElement>(`[data-row-index="${highlight}"]`)
110
+ row?.scrollIntoView({ block: 'nearest' })
111
+ }, [highlight])
112
+
113
+ if (!open) return null
114
+
115
+ const activeRow = visible[highlight]
116
+ const activeId = activeRow === undefined ? undefined : `${id}-row-${highlight}`
117
+ const onSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
118
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
119
+ event.preventDefault()
120
+ if (visible.length === 0) return
121
+ setHighlight((current) => {
122
+ const next = current + (event.key === 'ArrowDown' ? 1 : -1)
123
+ return (next + visible.length) % visible.length
124
+ })
125
+ return
126
+ }
127
+ if (event.key === 'Enter' && activeRow !== undefined && !busy) {
128
+ event.preventDefault()
129
+ pick(activeRow)
130
+ }
131
+ }
132
+ const pick = (row: CatalogRow) => {
133
+ const selection = planModelCommit(live, row.provider, row.model, reason)
134
+ if (selection === null) {
135
+ onClose()
136
+ return
137
+ }
138
+ onPick(selection)
139
+ }
140
+ /** Move to another tab and hand it the keyboard, in one step. */
141
+ const goTab = (next: PickerTab | undefined) => {
142
+ if (next === undefined || next.id === tab) return
143
+ setTab(next.id)
144
+ setHighlight(0)
145
+ tabRefs.current.get(next.id)?.focus()
146
+ }
147
+ const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
148
+ const index = tabs.findIndex((candidate) => candidate.id === tab)
149
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
150
+ event.preventDefault()
151
+ const step = event.key === 'ArrowRight' ? 1 : -1
152
+ goTab(tabs[((index === -1 ? 0 : index) + step + tabs.length) % tabs.length])
153
+ return
154
+ }
155
+ if (event.key === 'Home' || event.key === 'End') {
156
+ event.preventDefault()
157
+ goTab(tabs[event.key === 'Home' ? 0 : tabs.length - 1])
158
+ }
159
+ }
160
+
161
+ const failures = state.failures.filter((failure) => tab === ALL_TAB || failure.id === tab)
162
+ const failed = state.error !== null && state.status === 'error'
163
+ const loading = state.status === 'loading'
164
+ return (
165
+ <Modal
166
+ open={open}
167
+ onClose={onClose}
168
+ title={t('picker.title')}
169
+ closeLabel={t('picker.close')}
170
+ className={css.pickerDialog ?? ''}
171
+ contentClassName={css.pickerContent ?? ''}
172
+ >
173
+ <div className={css.pickerSearch}>
174
+ <Input
175
+ ref={searchRef}
176
+ className={css.pickerSearchInput ?? ''}
177
+ type="text"
178
+ role="searchbox"
179
+ aria-label={t('picker.search')}
180
+ aria-controls={`${id}-list`}
181
+ aria-activedescendant={activeId}
182
+ placeholder={t('picker.search')}
183
+ icon={<IconSearchOutlineRegular size={14} />}
184
+ value={query}
185
+ readOnly={busy}
186
+ onChange={(event) => { setQuery(event.target.value) }}
187
+ onKeyDown={onSearchKeyDown}
188
+ data-modal-autofocus=""
189
+ />
190
+ </div>
191
+ <div className={css.pickerTabs} role="tablist" aria-label={t('picker.title')}>
192
+ {tabs.map((entry) => (
193
+ <button
194
+ key={entry.id}
195
+ ref={(node) => {
196
+ if (node === null) tabRefs.current.delete(entry.id)
197
+ else tabRefs.current.set(entry.id, node)
198
+ }}
199
+ type="button"
200
+ role="tab"
201
+ id={`${id}-tab-${entry.id}`}
202
+ aria-selected={entry.id === tab}
203
+ aria-controls={`${id}-list`}
204
+ tabIndex={0}
205
+ className={css.pickerTab}
206
+ onClick={() => {
207
+ setTab(entry.id)
208
+ setHighlight(0)
209
+ }}
210
+ onKeyDown={onTabKeyDown}
211
+ onFocus={() => {
212
+ if (entry.id !== tab) {
213
+ setTab(entry.id)
214
+ setHighlight(0)
215
+ }
216
+ }}
217
+ >
218
+ {entry.label}
219
+ </button>
220
+ ))}
221
+ </div>
222
+ <div
223
+ ref={listRef}
224
+ id={`${id}-list`}
225
+ className={css.pickerList}
226
+ role="menu"
227
+ aria-label={t('picker.title')}
228
+ >
229
+ {loading && <div className={css.notice}>{t('status.loading')}</div>}
230
+ {failed && (
231
+ <div className={css.notice}>
232
+ <span>{t('status.failed', { message: state.error ?? '' })}</span>
233
+ <button type="button" className={css.retry} onClick={onReload}>{t('action.reload')}</button>
234
+ </div>
235
+ )}
236
+ {failures.map((failure) => (
237
+ <div className={css.notice} key={failure.id}>
238
+ <span>{t('status.providerFailed', { name: failure.name, message: failure.message })}</span>
239
+ <button type="button" className={css.retry} onClick={onReload}>{t('action.reload')}</button>
240
+ </div>
241
+ ))}
242
+ {visible.length === 0 && !loading && !failed && (
243
+ <div className={css.notice}>{state.groups.length === 0 ? t('status.empty') : t('picker.searchEmpty')}</div>
244
+ )}
245
+ {visible.map((row, index) => (
246
+ <div key={`${row.provider}/${row.model}`} data-row-index={index} id={`${id}-row-${index}`}>
247
+ <ModelRow
248
+ name={row.entry.name}
249
+ selected={live?.provider === row.provider && live.model === row.model}
250
+ busy={busy}
251
+ star="interactive"
252
+ favorited={isFavorite(favorites, row.provider, row.model)}
253
+ meta={metaLine({
254
+ row,
255
+ facts: facts(row.provider, row.model),
256
+ providerName: tab === ALL_TAB ? row.providerName : undefined,
257
+ thinks: row.entry.reasoning !== undefined,
258
+ effortLabel: t('meta.effort'),
259
+ imageLabel: t('meta.images'),
260
+ })}
261
+ starLabel={isFavorite(favorites, row.provider, row.model)
262
+ ? t('picker.unfavorite', { model: row.entry.name })
263
+ : t('picker.favorite', { model: row.entry.name })}
264
+ starTabStop={index === highlight}
265
+ rowTabIndex={-1}
266
+ onPick={() => { pick(row) }}
267
+ onFocusRow={() => { setHighlight(index) }}
268
+ onToggleFavorite={() => { onToggleFavorite(row.provider, row.model) }}
269
+ />
270
+ </div>
271
+ ))}
272
+ </div>
273
+ <div className={css.pickerFooter}>
274
+ <span className={css.footerHint}>
275
+ <ShortcutKeys keys={['↑', '↓']} />
276
+ <span>{t('picker.hintNavigate')}</span>
277
+ <span className={css.footerDot}>·</span>
278
+ <ShortcutKeys keys={['Enter']} />
279
+ <span>{t('picker.hintSelect')}</span>
280
+ <span className={css.footerDot}>·</span>
281
+ <ShortcutKeys keys={['Esc']} />
282
+ <span>{t('picker.hintClose')}</span>
283
+ </span>
284
+ </div>
285
+ </Modal>
286
+ )
287
+ }
288
+
289
+ function metaLine(input: {
290
+ readonly row: CatalogRow
291
+ readonly facts: ModelFacts
292
+ readonly providerName: string | undefined
293
+ readonly thinks: boolean
294
+ readonly effortLabel: string
295
+ readonly imageLabel: string
296
+ }): string {
297
+ return metaSegments({
298
+ ...input.providerName === undefined ? {} : { providerName: input.providerName },
299
+ facts: input.facts,
300
+ thinks: input.thinks,
301
+ effortLabel: input.effortLabel,
302
+ imageLabel: input.imageLabel,
303
+ }).join(' · ')
304
+ }
305
+
@@ -0,0 +1,127 @@
1
+ /**
2
+ * One pickable model, as both surfaces render it.
3
+ *
4
+ * The menu row is one button that spans the whole width — name, favorite mark,
5
+ * and the check for the model in use all live inside it, so hovering any part of
6
+ * the row (including the star) highlights the row and clicking any part picks
7
+ * the model. The picker row keeps the same shape but lifts the star out into its
8
+ * own control, because favouriting there must not switch the model.
9
+ */
10
+
11
+ import type { ReactNode } from 'react'
12
+ import { IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import { StarIcon } from './icons.tsx'
14
+ import css from './ModelSubmenu.module.css'
15
+
16
+ /** How one row shows its favorite mark: part of the row, or a control of its own. */
17
+ export type StarMode = 'static' | 'interactive'
18
+
19
+ /** Props shared by both row shapes. */
20
+ export interface ModelRowProps {
21
+ /** The model's catalog name. */
22
+ readonly name: string
23
+ /** Whether this row is the model in use. */
24
+ readonly selected: boolean
25
+ /** Whether a selection is in flight, which locks the row. */
26
+ readonly busy: boolean
27
+ /** How the favorite mark behaves on this row. */
28
+ readonly star: StarMode
29
+ /** Whether the model is favorited right now. */
30
+ readonly favorited: boolean
31
+ /** The metadata line's text; undefined in the menu. */
32
+ readonly meta?: string | undefined
33
+ /** Localized accessible name for the star control. */
34
+ readonly starLabel?: string | undefined
35
+ /** Whether the star is a Tab stop right now (the picker's roving highlight). */
36
+ readonly starTabStop?: boolean
37
+ /** Tab order of the row itself; the picker keeps rows out of it so ↑/↓ own the walk. */
38
+ readonly rowTabIndex?: number
39
+ /** Pick this row. */
40
+ readonly onPick: () => void
41
+ /** The row took the keyboard, so its star becomes reachable. */
42
+ readonly onFocusRow?: (() => void) | undefined
43
+ /** Toggle this row's favorite; absent when the star is part of the row. */
44
+ readonly onToggleFavorite?: (() => void) | undefined
45
+ /** Extra row content, such as the picker's disabled reason. */
46
+ readonly children?: ReactNode
47
+ }
48
+
49
+ /**
50
+ * Render one model row.
51
+ * @param props - the row's content and behaviour.
52
+ * @returns the row element.
53
+ */
54
+ export function ModelRow({
55
+ name, selected, busy, star, favorited, meta, starLabel,
56
+ starTabStop = false, rowTabIndex, onPick, onFocusRow, onToggleFavorite, children,
57
+ }: ModelRowProps) {
58
+ const copy = (
59
+ <>
60
+ <span className={css.rowCopy}>
61
+ <span className={css.rowName}>{name}</span>
62
+ {meta !== undefined && meta !== '' && <span className={css.rowMeta}>{meta}</span>}
63
+ </span>
64
+ </>
65
+ )
66
+ const mark = favorited ? <StarIcon filled className={css.star} /> : null
67
+
68
+ // The menu's row is one target: the star and the check sit inside the button,
69
+ // so they inherit its hover fill and its click.
70
+ if (star === 'static') {
71
+ return (
72
+ <button
73
+ type="button"
74
+ role="menuitemradio"
75
+ aria-checked={selected}
76
+ tabIndex={rowTabIndex}
77
+ className={css.rowPick}
78
+ title={name}
79
+ disabled={busy}
80
+ onClick={onPick}
81
+ onFocus={onFocusRow}
82
+ data-selected={selected ? '' : undefined}
83
+ data-favorited={favorited ? '' : undefined}
84
+ >
85
+ {copy}
86
+ {mark !== null && <span className={css.starStatic} aria-hidden="true">{mark}</span>}
87
+ <span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
88
+ {children}
89
+ </button>
90
+ )
91
+ }
92
+
93
+ return (
94
+ <div
95
+ className={css.row}
96
+ data-selected={selected ? '' : undefined}
97
+ data-favorited={favorited ? '' : undefined}
98
+ >
99
+ <button
100
+ type="button"
101
+ role="menuitemradio"
102
+ aria-checked={selected}
103
+ tabIndex={rowTabIndex}
104
+ className={css.rowPick}
105
+ title={name}
106
+ disabled={busy}
107
+ onClick={onPick}
108
+ onFocus={onFocusRow}
109
+ >
110
+ {copy}
111
+ </button>
112
+ <button
113
+ type="button"
114
+ className={css.starButton}
115
+ aria-pressed={favorited}
116
+ aria-label={starLabel}
117
+ tabIndex={starTabStop ? 0 : -1}
118
+ disabled={busy}
119
+ onClick={onToggleFavorite}
120
+ >
121
+ <StarIcon filled={favorited} className={css.star} />
122
+ </button>
123
+ <span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
124
+ {children}
125
+ </div>
126
+ )
127
+ }