Files
deepseek-harness/packages/client/ui-model/src/client/ModelSelect.tsx
T
imccyu 2c5114c060 feat(client): adopt the locale seat in theme, sidebar, question, and model
Each package ships its zh/en dictionaries as satisfies-typed pairs (zh is
the key-set source of truth; en is checked complete against it), merges its
namespace into LocaleNamespaceMap, and declares locale: NS at register —
components read the framework-injected typed t seat instead of a
hand-carried inject member. Overlapping verbatim words (retry, submit,
submitting) drop out of package dictionaries in favor of the shared common
vocabulary; the question composer stores validation feedback as dictionary
keys so shown feedback follows a locale switch.
2026-07-30 01:04:58 +08:00

334 lines
13 KiB
TypeScript

/**
* ModelSelect: the composer's named model seat (`conversation.input.model`).
* Two-level selection per figma 496:26454's MenuDropdown: the root menu is
* the Model / Effort row pair (label + current value + a right chevron),
* each drilling into its own list — the provider-grouped model list over
* the shared directory, and the effort levels. The trigger (313:14108's
* ToggleButton) shows both: model name + effort in the caption tone.
* Data and submission ride the SAME per-session ModelDirectory as the
* /model popup; exact-model reasoning metadata and the selected effort come
* from the Host rather than a client-owned vocabulary.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
/** Which pane the dropdown shows: the two-row root or one drilled-in list. */
type Pane = 'root' | 'model' | 'effort'
/** One dynamic effort row; undefined means preserve the provider default. */
interface EffortChoice {
key: string
effort: string | undefined
label: string
description?: string
}
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory
* store/verbs) + the standard locale seat.
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect(
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
)
const [open, setOpen] = useState(false)
const [pane, setPane] = useState<Pane>('root')
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const choices = useMemo(() => state.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: {
provider: group.id,
model: model.id,
...model.reasoning?.defaultEffort === undefined
? {}
: { reasoningEffort: model.reasoning.defaultEffort },
} satisfies ModelTarget,
}))), [state.groups])
const selectedIndex = state.current === null
? -1
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model)
const currentChoice = choices[selectedIndex]
const reasoning = currentChoice?.model.reasoning
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? t('effort.providerDefault')
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
...reasoning.defaultEffort === undefined
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
effort: effort.id,
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning, t])
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
const show = (): void => {
setPane('root')
setOpen(true)
load()
}
const close = (restoreFocus = false): void => {
setOpen(false)
setPane('root')
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
const items = itemRefs.current.filter(item => item !== null)
if (items.length === 0) return
const active = items.findIndex(item => item === document.activeElement)
const next = (Math.max(active, 0) + offset + items.length) % items.length
items[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
// Escape backs out of a drilled pane first, then closes.
if (pane !== 'root') setPane('root')
else close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
}
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (state.current?.provider === target.provider && state.current.model === target.model) {
close(true)
return
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const chooseEffort = (effort: string | undefined): void => {
if (state.current === null) return
if (effectiveEffort === effort) {
close(true)
return
}
const target: ModelTarget = {
provider: state.current.provider,
model: state.current.model,
...effort === undefined ? {} : { reasoningEffort: effort },
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
const at = itemIndex++
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
}
return (
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={triggerLabel}
disabled={locked}
onClick={() => {
if (open) {
close()
} else {
show()
}
}}
>
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label={t('menu.aria')}
aria-busy={state.status === 'loading' || busy}
>
{pane === 'root' && (
<>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
<span className={css.cellLabel}>{t('menu.model')}</span>
<span className={css.cellValue}>{modelLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
{reasoning !== undefined && (
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
<span className={css.cellLabel}>{t('menu.effort')}</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
)}
</>
)}
{pane === 'model' && (
<>
{state.status === 'loading' && (
<div className={css.status}>{t('status.loading')}</div>
)}
{state.error !== null && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
{state.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section role="group" aria-labelledby={headingId} className={css.group} key={group.id}>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const selected = state.current?.provider === group.id && state.current.model === model.id
return (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}>{t('empty.models')}</div>
)}
</>
)}
{pane === 'effort' && (
<>
{state.error !== null && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}>{t('empty.efforts')}</div>
: effortChoices.map(level => (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={effectiveEffort === level.effort}
className={clsx(css.option, effectiveEffort === level.effort && css.selected)}
key={level.key}
disabled={busy}
onClick={() => { chooseEffort(level.effort) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{level.label}</span>
{level.description !== undefined && (
<span className={css.description}>{level.description}</span>
)}
</span>
<span className={css.check}>
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
</span>
</button>
))}
</>
)}
</div>
)}
</div>
)
}