From 72717b7b7857bc7c85c7ffe7dffdfc38d9ce1102 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:01:47 +0800 Subject: [PATCH] Two-level model/effort selection per the MenuDropdown mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat's dropdown follows figma 496:26454: the root pane is the Model / Effort cell pair (14/22 label, value in the tertiary tone, right chevron), each drilling into its own list — the provider-grouped model list over the shared directory, and the High/Max effort levels. The trigger (313:14108) shows both values: model name plus effort in the caption tone. Effort is a client-local display echo on the shared directory state for now — the design pairs the two as one selection, but no wire carries a per-session effort override yet (the deepseek adapter's reasoningEffort is deployment config); the directory state documents that boundary, so wiring it later is a submit-path change, not a UI one. Escape backs out of a drilled pane before closing. --- .../src/client/ModelSelect.module.css | 56 +++- .../ui-model/src/client/ModelSelect.tsx | 250 ++++++++++-------- .../client/ui-model/src/client/directory.ts | 30 ++- packages/client/ui-model/src/client/index.ts | 3 +- packages/client/ui-model/src/client/slots.ts | 8 +- 5 files changed, 230 insertions(+), 117 deletions(-) diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index f3b044496c..1341544c53 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -45,6 +45,12 @@ white-space: nowrap; } +/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */ +.triggerEffort { + flex: 0 0 auto; + color: var(--dsw-alias-label-caption); +} + .chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); @@ -65,9 +71,9 @@ width: min(320px, calc(100vw - 32px)); max-height: min(360px, calc(100vh - 96px)); overflow: hidden; - padding: 6px; + padding: 4px; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 14px; + border-radius: 12px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv3); color: var(--dsw-alias-label-primary); @@ -197,3 +203,49 @@ flex: 0 0 18px; color: var(--dsw-alias-state-business-primary); } + +/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side + padding, 8px gap, 10px radius; 14/22 label in primary, value in the + #81858C tertiary tone, right chevron drilling into the sub-list. */ +.cell { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 40px; + padding: 0 10px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 22px; + cursor: pointer; + text-align: left; +} + +.cell:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cellLabel { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cellValue { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-tertiary); +} + +.cellChevron { + flex: 0 0 auto; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 86068777e4..16db0acd27 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -1,10 +1,13 @@ /** * ModelSelect: the composer's named model seat (`conversation.input.model`). - * Compact trigger + upward provider-grouped single-select menu, revived from - * the original PR #600 ModelSelector form. Data and submission ride the SAME - * per-session ModelDirectory as the /model popup — one shared current, one - * catalog load path, one selectModel route: a switch in either entry is what - * the other shows next. + * 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; effort is a client-local display echo until a wire carries + * a per-session override (see the directory's state contract). */ import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, @@ -12,27 +15,37 @@ import { } from 'react' import clsx from 'clsx' import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' -import { IconCheckOutline16, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ModelEffort } from './directory.ts' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' -type FocusPreference = 'current' | 'first' | 'last' +/** The displayable effort levels (deepseek wire vocabulary, capitalized for the UI). */ +const EFFORT_LEVELS: readonly { id: ModelEffort; label: string }[] = [ + { id: 'high', label: 'High' }, + { id: 'max', label: 'Max' }, +] + +/** Which pane the dropdown shows: the two-row root or one drilled-in list. */ +type Pane = 'root' | 'model' | 'effort' /** * Render the composer model seat. * @param props - owner share (locked) + injected face (shared directory store/verbs). - * @returns the trigger and, while open, the upward menu. + * @returns the trigger and, while open, the two-level menu. */ -export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { +export function ModelSelect({ locked, directory, load, select, setEffort }: ModelSelectInjected & { locked: boolean }) { const state = useSyncExternalStore( fn => directory.subscribe(fn), () => directory.getSnapshot(), ) const [open, setOpen] = useState(false) + const [pane, setPane] = useState('root') const rootRef = useRef(null) const triggerRef = useRef(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) - const pendingFocus = useRef(null) const id = useId() const choices = useMemo(() => state.groups.flatMap(group => @@ -45,6 +58,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje ? -1 : choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model) const busy = state.status === 'selecting' + const effortLabel = EFFORT_LEVELS.find(l => l.id === state.effort)?.label ?? 'High' // Mount-time load resolves the trigger label; every open refreshes. useEffect(() => { load() }, [load]) @@ -58,66 +72,39 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje return () => { document.removeEventListener('mousedown', closeOutside) } }, [open]) - useEffect(() => { - const preference = pendingFocus.current - if (!open || preference === null || choices.length === 0) return - const index = preference === 'first' - ? 0 - : preference === 'last' - ? choices.length - 1 - : selectedIndex >= 0 ? selectedIndex : 0 - itemRefs.current[index]?.focus() - pendingFocus.current = null - }, [choices.length, open, selectedIndex]) - - const show = (preference: FocusPreference | null = null): void => { - pendingFocus.current = preference + const show = (): void => { + setPane('root') setOpen(true) load() } const close = (restoreFocus = false): void => { setOpen(false) - pendingFocus.current = null + setPane('root') if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() }) } const moveFocus = (offset: number): void => { - if (choices.length === 0) return - const active = itemRefs.current.findIndex(item => item === document.activeElement) - const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0 - const next = (origin + offset + choices.length) % choices.length - itemRefs.current[next]?.focus() + 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): void => { if (event.key === 'Escape' && open) { event.preventDefault() - close(true) + // 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) - return } - if (event.key === 'Home' || event.key === 'End') { - event.preventDefault() - itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus() - } - } - - const onTriggerKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return - event.preventDefault() - if (!open) { - show(event.key === 'ArrowDown' ? 'first' : 'last') - return - } - pendingFocus.current = 'current' - const index = selectedIndex >= 0 ? selectedIndex : 0 - itemRefs.current[index]?.focus() } const onBlur = (event: FocusEvent): void => { @@ -135,7 +122,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje }) } - const label = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + itemRefs.current = [] + let itemIndex = 0 + const itemRef = () => { + const at = itemIndex++ + return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node } + } return (
@@ -143,16 +136,16 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje ref={triggerRef} type="button" className={css.trigger} - aria-label={`选择模型,当前 ${label}`} + aria-label={`选择模型,当前 ${modelLabel},effort ${effortLabel}`} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} - title={label} + title={`${modelLabel} · ${effortLabel}`} disabled={locked} onClick={() => { open ? close() : show() }} - onKeyDown={onTriggerKeyDown} > - {label} + {modelLabel} + {effortLabel} @@ -161,69 +154,104 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje id={`${id}-menu`} className={css.menu} role="menu" - aria-label="模型" + aria-label="模型与 effort" aria-busy={state.status === 'loading' || busy} > - {state.status === 'loading' && ( -
正在刷新模型列表…
+ {pane === 'root' && ( + <> + + + )} - {state.error !== null && ( -
- 模型操作失败:{state.error} - -
+ + {pane === 'model' && ( + <> + {state.status === 'loading' && ( +
正在刷新模型列表…
+ )} + {state.error !== null && ( +
+ 模型操作失败:{state.error} + +
+ )} + {state.failures.map(failure => ( +
+ {failure.name} 加载失败:{failure.message} + +
+ ))} +
+ {state.groups.map((group) => { + const headingId = `${id}-${group.id}` + return ( +
+
{group.name}
+ {group.models.map((model) => { + const selected = state.current?.provider === group.id && state.current.model === model.id + return ( + + ) + })} +
+ ) + })} +
+ {state.status === 'ready' && choices.length === 0 && ( +
没有可用的模型。
+ )} + )} - {state.failures.map(failure => ( -
- {failure.name} 加载失败:{failure.message} - -
+ + {pane === 'effort' && EFFORT_LEVELS.map(level => ( + ))} - -
- {state.groups.map((group) => { - const headingId = `${id}-${group.id}` - return ( -
-
{group.name}
- {group.models.map((model) => { - const index = choices.findIndex(c => c.target.provider === group.id && c.target.model === model.id) - const selected = state.current?.provider === group.id && state.current.model === model.id - return ( - - ) - })} -
- ) - })} -
- - {state.status === 'ready' && choices.length === 0 && ( -
没有可用的模型。
- )}
)} diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index 8252790b53..73c84115e7 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -11,8 +11,19 @@ import type { import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +/** Thinking-effort display levels (the deepseek wire vocabulary). */ +export type ModelEffort = 'high' | 'max' + /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { + /** + * Displayed thinking-effort level. Client-local echo only for now: the + * design pairs model and effort as one two-level selection, but no wire + * carries a per-session effort override yet (the deepseek adapter's + * reasoningEffort is deployment config) — selecting it updates this + * display state and nothing else. + */ + effort: ModelEffort /** Target the host reports for the next assembled step; null before the first load. */ current: ModelTarget | null /** Successfully loaded provider groups (last good load). */ @@ -29,7 +40,7 @@ export interface ModelDirectoryState { export class ModelDirectory { /** The shared snapshot both entries render from (uSES-safe store). */ readonly store: SnapshotStore = createSnapshotStore({ - current: null, groups: [], failures: [], status: 'idle', error: null, + effort: 'high', current: null, groups: [], failures: [], status: 'idle', error: null, }) /** Latest operation wins; an older response never overwrites a newer one. */ @@ -63,7 +74,13 @@ export class ModelDirectory { throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) } const { current, groups, failures } = result.value - this.store.set({ current, groups, failures, status: 'ready', error: null }) + this.store.update((s) => { + s.current = current + s.groups = groups + s.failures = failures + s.status = 'ready' + s.error = null + }) return result.value } @@ -90,6 +107,15 @@ export class ModelDirectory { this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null }) } + /** + * Set the displayed effort level (client-local; see the state field's contract). + * @param effort - the level to display. + */ + setEffort(effort: ModelEffort): void { + if (this.disposed) return + this.store.update((s) => { s.effort = effort }) + } + /** Scope teardown: late settlements lose write access to the store. */ dispose(): void { this.disposed = true diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index 83bcf9ba31..2afd0638e8 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -20,7 +20,7 @@ import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' export { ModelDirectory } from './directory.ts' -export type { ModelDirectoryState } from './directory.ts' +export type { ModelDirectoryState, ModelEffort } from './directory.ts' export { ModelService } from './service.ts' export type { ModelSelectInjected } from './slots.ts' @@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void { directory: directory.store, load: () => { directory.load().catch(() => { /* surfaced on the store */ }) }, select: (target: ModelTarget) => directory.select(target).then(() => true, () => false), + setEffort: effort => directory.setEffort(effort), } }, }, ModelSelect), 'ui-model: composer model seat registration') diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts index fdbd1a744e..9b3a1ac351 100644 --- a/packages/client/ui-model/src/client/slots.ts +++ b/packages/client/ui-model/src/client/slots.ts @@ -6,7 +6,7 @@ */ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { ModelDirectoryState } from './directory.ts' +import type { ModelDirectoryState, ModelEffort } from './directory.ts' /** Injected business face of the composer model seat. */ export interface ModelSelectInjected { @@ -20,4 +20,10 @@ export interface ModelSelectInjected { * @returns whether the host accepted the selection. */ select(target: ModelTarget): Promise + /** + * Set the displayed thinking-effort level (client-local echo; see the + * directory state contract). + * @param effort - the level to display. + */ + setEffort(effort: ModelEffort): void }