소스 검색

Two-level model/effort selection per the MenuDropdown mock

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.
imccyu 2 달 전
부모
커밋
72717b7b78

+ 54 - 2
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);
+}

+ 139 - 111
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<Pane>('root')
   const rootRef = useRef<HTMLDivElement | null>(null)
   const triggerRef = useRef<HTMLButtonElement | null>(null)
   const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
-  const pendingFocus = useRef<FocusPreference | null>(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<HTMLDivElement>): 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<HTMLButtonElement>): 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<HTMLDivElement>): 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 (
     <div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
@@ -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}
       >
-        <span className={css.triggerLabel}>{label}</span>
+        <span className={css.triggerLabel}>{modelLabel}</span>
+        <span className={css.triggerEffort}>{effortLabel}</span>
         <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
       </button>
 
@@ -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' && (
-            <div className={css.status}>正在刷新模型列表…</div>
+          {pane === 'root' && (
+            <>
+              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => setPane('model')}>
+                <span className={css.cellLabel}>Model</span>
+                <span className={css.cellValue}>{modelLabel}</span>
+                <IconChevronRightOutline14 className={css.cellChevron} />
+              </button>
+              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => setPane('effort')}>
+                <span className={css.cellLabel}>Effort</span>
+                <span className={css.cellValue}>{effortLabel}</span>
+                <IconChevronRightOutline14 className={css.cellChevron} />
+              </button>
+            </>
           )}
-          {state.error !== null && (
-            <div className={css.error}>
-              <span>模型操作失败:{state.error}</span>
-              <button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
-            </div>
-          )}
-          {state.failures.map(failure => (
-            <div className={css.warning} key={failure.id}>
-              <span>{failure.name} 加载失败:{failure.message}</span>
-              <button type="button" className={css.retry} onClick={() => { load() }}>重试</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 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 (
-                      <button
-                        ref={(node) => { itemRefs.current[index] = node }}
-                        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}>当前模型 · 未列入目录</span>
-                          )}
-                        </span>
-                        <span className={css.check}>
-                          {selected ? <IconCheckOutline16 /> : null}
-                        </span>
-                      </button>
-                    )
-                  })}
-                </section>
-              )
-            })}
-          </div>
-
-          {state.status === 'ready' && choices.length === 0 && (
-            <div className={css.empty}>没有可用的模型。</div>
+          {pane === 'model' && (
+            <>
+              {state.status === 'loading' && (
+                <div className={css.status}>正在刷新模型列表…</div>
+              )}
+              {state.error !== null && (
+                <div className={css.error}>
+                  <span>模型操作失败:{state.error}</span>
+                  <button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
+                </div>
+              )}
+              {state.failures.map(failure => (
+                <div className={css.warning} key={failure.id}>
+                  <span>{failure.name} 加载失败:{failure.message}</span>
+                  <button type="button" className={css.retry} onClick={() => { load() }}>重试</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}>当前模型 · 未列入目录</span>
+                              )}
+                            </span>
+                            <span className={css.check}>
+                              {selected ? <IconCheckOutline16 /> : null}
+                            </span>
+                          </button>
+                        )
+                      })}
+                    </section>
+                  )
+                })}
+              </div>
+              {state.status === 'ready' && choices.length === 0 && (
+                <div className={css.empty}>没有可用的模型。</div>
+              )}
+            </>
           )}
+
+          {pane === 'effort' && EFFORT_LEVELS.map(level => (
+            <button
+              ref={itemRef()}
+              type="button"
+              role="menuitemradio"
+              aria-checked={state.effort === level.id}
+              className={clsx(css.option, state.effort === level.id && css.selected)}
+              key={level.id}
+              onClick={() => { setEffort(level.id); close(true) }}
+            >
+              <span className={css.optionCopy}>
+                <span className={css.modelName}>{level.label}</span>
+              </span>
+              <span className={css.check}>
+                {state.effort === level.id ? <IconCheckOutline16 /> : null}
+              </span>
+            </button>
+          ))}
         </div>
       )}
     </div>

+ 28 - 2
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<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
-    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

+ 2 - 1
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')

+ 7 - 1
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<boolean>
+  /**
+   * Set the displayed thinking-effort level (client-local echo; see the
+   * directory state contract).
+   * @param effort - the level to display.
+   */
+  setEffort(effort: ModelEffort): void
 }