@bittersmilezzz/dsh-model-selector 0.1.21 → 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.
package/lib/client.js CHANGED
@@ -119,6 +119,37 @@ window.__ModuleLoader__.load({
119
119
  }
120
120
  return best?.id;
121
121
  }
122
+ /**
123
+ * 已知档位的秩;适配器自造的非规范 id(如 'turbo')返回 undefined —— 与
124
+ * 「已知但秩为 0」(off)区分开。maxEffortOf 对并列的未知档取首个,把它当
125
+ * 「最强档」提交等于随机挑一个档、还可能被宿主拒,故调用方必须先过这一关。
126
+ */
127
+ function dmsEffortRank(id) {
128
+ return Object.prototype.hasOwnProperty.call(EFFORT_RANK, id) ? EFFORT_RANK[id] : void 0;
129
+ }
130
+ /**
131
+ * 选择模型时自动落到最强档的决策(从 choose() 搬出并修正):
132
+ * - 非推理模型 / 无档位 → 不提交 effort;
133
+ * - 最强档是 'off'(模型只提供 off)→ 不提交;
134
+ * - 最强档是**非规范 id**(rank 未知)→ 不提交:maxEffortOf 对并列未知档取
135
+ * 首个,把它当「最强」提交会挑错档甚至被宿主拒(用户看到「切换失败」);
136
+ * - 否则提交已知最强档;只有它不等于模型声明的默认档时才播报落点。
137
+ */
138
+ function dmsChoosePlan(reasoning) {
139
+ if (reasoning === void 0) return {
140
+ effort: void 0,
141
+ autoRaised: false
142
+ };
143
+ const best = maxEffortOf(reasoning);
144
+ if (best === void 0 || best === "off" || dmsEffortRank(best) === void 0) return {
145
+ effort: void 0,
146
+ autoRaised: false
147
+ };
148
+ return {
149
+ effort: best,
150
+ autoRaised: best !== reasoning.defaultEffort
151
+ };
152
+ }
122
153
  function dmsEffortIndex(levels, id) {
123
154
  return levels.findIndex((level) => level.id === id);
124
155
  }
@@ -168,12 +199,15 @@ window.__ModuleLoader__.load({
168
199
  * 只有当自回滚以来没有任何新的提交改写 committedRef(仍等于回滚前的档位)、
169
200
  * 提交纪元未推进(epoch === epochAtCommit,即期间没有发起过新提交——提交失败
170
201
  * 回滚后 committedRef 会回到与上一轮回滚相同的值,值比较无法区分「无新提交」
171
- * 与「新提交失败回滚」,纪元才能区分)、且当前无拖动/提交在途时,迟到结果才
172
- * 仍对应当前唯一的意图链——此时 UI 处于「回滚但后端已生效」的错位态,应补
173
- * 一次同步而不是保持回滚。否则以新操作链为准。
202
+ * 与「新提交失败回滚」,纪元才能区分)、当前无拖动/提交在途,**且生效模型仍是
203
+ * 发起提交时的那个**(sameModel)时,迟到结果才仍对应当前唯一的意图链——此时
204
+ * UI 处于「回滚但后端已生效」的错位态,应补一次同步而不是保持回滚。
205
+ * 否则以新操作链为准:模型被外部改写(另一入口切换 / 宿主推送新投影 / 重连后
206
+ * 同步出别的选择)时,next/index 是在旧模型的档位表上算出来的,写回 UI 会显示
207
+ * 一个后端并未生效的档位。
174
208
  */
175
- function dmsShouldAdoptLateSuccess(committed, previous, dragging, committing, epoch, epochAtCommit) {
176
- return committed === previous && epoch === epochAtCommit && !dragging && !committing;
209
+ function dmsShouldAdoptLateSuccess(committed, previous, dragging, committing, epoch, epochAtCommit, sameModel) {
210
+ return sameModel && committed === previous && epoch === epochAtCommit && !dragging && !committing;
177
211
  }
178
212
  /**
179
213
  * 拖动终止事件是否对应当前活动拖动:pointerActive 为假(拖动已结束/从未开始)
@@ -525,6 +559,11 @@ window.__ModuleLoader__.load({
525
559
  * 拖出屏外)时,返回钳到视口内的 left 值,调用方改用 left 锚定。
526
560
  * 返回 undefined 表示保持默认右锚定。
527
561
  * menuWidth 传菜单实际渲染宽度(offsetWidth),与 CSS `min(280px, 100vw-32px)` 解耦。
562
+ *
563
+ * **坐标系**:入参与返回值都是**视口坐标**。菜单是 .dms-root(position:
564
+ * relative)内的 absolute 元素,left 的参考系是 root 的 padding box —— 调用方
565
+ * 必须减去 root 的左缘再写进 style.left,否则窄窗口下菜单整体右移、横向溢出
566
+ * 时整幅画到屏外。
528
567
  */
529
568
  function dmsMenuLeft(rootRight, menuWidth, viewportWidth, margin) {
530
569
  if (rootRight - menuWidth >= margin && rootRight <= viewportWidth - margin) return void 0;
@@ -533,6 +572,26 @@ window.__ModuleLoader__.load({
533
572
  //#endregion
534
573
  //#region src/client/roving.ts
535
574
  /**
575
+ * dsh-model-selector — 菜单 roving tabindex 的纯决策逻辑(node 可测)。
576
+ *
577
+ * 模型列表可达数百行,若每行都是 Tab 停靠点,Tab 键会陷入行海;roving
578
+ * tabindex(WAI-ARIA menu 模式的通行做法)只让「活动行」留在 Tab 序里,
579
+ * 其余行 tabindex=-1、仅方向键可达。本模块只负责一件事:给定当前渲染的
580
+ * 行集合与选中行,算出 Tab 从搜索框进入列表时应停靠的默认行键。
581
+ */
582
+ /**
583
+ * 模型行键的**唯一构造点**:菜单 DOM 的 `data-row-key`、roving 决策与行焦点
584
+ * 回调必须同源。此前这三处各自拼模板串,一次局部改名就能让 dmsDefaultRowKey
585
+ * 返回的键匹配不到任何行(Tab 落点静默失效),且测试拿自制副本验证不到。
586
+ */
587
+ function dmsRowKey(providerId, modelId) {
588
+ return `${providerId}\u0000${modelId}`;
589
+ }
590
+ /** 组头行键(分组视图里组头也参与方向键导航)。 */
591
+ function dmsHeaderKey(providerId) {
592
+ return `header:${providerId}`;
593
+ }
594
+ /**
536
595
  * Tab 默认落点:搜索态取「选中行若在命中集内,否则首个命中」;分组态取
537
596
  * 「选中行若可见,否则首个可见模型行」;全部折叠(无可见模型行)时回退到
538
597
  * 首个组头。返回 null 表示当前没有任何可聚焦行(空态由菜单外的 role=status
@@ -547,6 +606,144 @@ window.__ModuleLoader__.load({
547
606
  return set.headerKeys[0] ?? null;
548
607
  }
549
608
  //#endregion
609
+ //#region src/client/search.ts
610
+ /**
611
+ * 搜索归一化:NFKD 分解 → 去组合标记 → 小写。
612
+ *
613
+ * 比裸 `toLowerCase()` 宽容:`'İstanbul'` 能被 `'istanbul'` 命中(裸 toLowerCase
614
+ * 把 U+0130 折成 `'i̇'` 两个码位,与 ASCII `'i'` 反而不匹配 → 漏检;`'café'` 同理)。
615
+ * 注意它**可能改变码位长度**,所以不能用它的下标去切原串 —— 高亮区间另走
616
+ * {@link dmsNameHit},只在长度不变时给区间。
617
+ */
618
+ function dmsFold(text) {
619
+ return text.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase();
620
+ }
621
+ /** 搜索词归一化(去首尾空白后折叠)。空串 = 非搜索态。 */
622
+ function dmsNormalizeQuery(raw) {
623
+ return dmsFold(raw.trim());
624
+ }
625
+ /** 模型条目的搜索文本(haystack)。与 choices 构建处同源,避免两处漂移。 */
626
+ function dmsHaystack(group, model) {
627
+ return dmsFold(`${model.name}\n${model.description ?? ""}\n${group.name}\n${model.id}\n${group.id}`);
628
+ }
629
+ /**
630
+ * 名称内的命中区间:折叠后长度不变时用下标定位;长度变化(含会改变码位数的
631
+ * 大小写/变音符映射)时返回 null —— 宁可不高亮,也不能用错位的区间去切原串
632
+ * (那会把高亮画到别的字符上,甚至越界)。
633
+ */
634
+ function dmsNameHit(name, normalizedQuery) {
635
+ if (normalizedQuery === "") return null;
636
+ const folded = dmsFold(name);
637
+ if (folded.length !== name.length) return null;
638
+ const at = folded.indexOf(normalizedQuery);
639
+ return at < 0 ? null : {
640
+ start: at,
641
+ end: at + normalizedQuery.length
642
+ };
643
+ }
644
+ /**
645
+ * 搜索:遍历 choices 计数全部命中,只构造窗口内(前 limit 条)的渲染条目 ——
646
+ * 宽泛关键词(单字母)命中数百条时,每次击键省掉数百次对象分配与名称匹配;
647
+ * total 单独累计,播报与「仅显示前 N 条」提示仍然准确。
648
+ */
649
+ function dmsSearchHits(choices, normalizedQuery, limit) {
650
+ if (normalizedQuery === "") return null;
651
+ const items = [];
652
+ let total = 0;
653
+ for (const choice of choices) {
654
+ if (!choice.haystack.includes(normalizedQuery)) continue;
655
+ total += 1;
656
+ if (items.length >= limit) continue;
657
+ items.push({
658
+ group: choice.group,
659
+ model: choice.model,
660
+ nameHit: dmsNameHit(choice.model.name, normalizedQuery)
661
+ });
662
+ }
663
+ return {
664
+ items,
665
+ total
666
+ };
667
+ }
668
+ /**
669
+ * 空态判定:**无模型优先于无命中** —— 目录为空而用户又输了关键词时,真相是
670
+ * 「没有可用的模型」(该引导用户去配置/加载目录),不是「没有匹配关键词的模型」
671
+ * (那会引导用户去改关键词,而无论改什么都不会有结果)。
672
+ */
673
+ function dmsMenuEmptyState(input) {
674
+ if (input.status === "ready" && input.choiceCount === 0) return "noModels";
675
+ if (input.searching && input.hitCount === 0) return "noHits";
676
+ return null;
677
+ }
678
+ //#endregion
679
+ //#region src/client/keys.ts
680
+ /**
681
+ * 按键 → 动作。无行可去时对方向键/Home/End 返回 none 而不是吞键:列表为空
682
+ * (加载中/无命中/全折叠)时搜索框里的上下键仍应有移光标语义,preventDefault
683
+ * 由调用方在真正执行动作时才发。
684
+ */
685
+ function dmsMenuKeyAction(input) {
686
+ if (input.composing) return { type: "none" };
687
+ const { key, open, fromSearch, inMenu, onNonInput, hasQuery, rowCount, hitCount } = input;
688
+ if (key === "Escape" && open) {
689
+ if (hasQuery && inMenu) return {
690
+ type: "clearQuery",
691
+ focusSearch: !fromSearch
692
+ };
693
+ return { type: "close" };
694
+ }
695
+ if (!open) return { type: "none" };
696
+ if ((key === "ArrowDown" || key === "ArrowUp") && (onNonInput || fromSearch)) {
697
+ if (rowCount === 0) return { type: "none" };
698
+ return {
699
+ type: "moveFocus",
700
+ offset: key === "ArrowDown" ? 1 : -1
701
+ };
702
+ }
703
+ if ((key === "Home" || key === "End") && onNonInput) {
704
+ if (rowCount === 0) return { type: "none" };
705
+ return {
706
+ type: "focusEdge",
707
+ last: key === "End"
708
+ };
709
+ }
710
+ if (key === "Enter" && fromSearch && hitCount > 0) return { type: "chooseFirstHit" };
711
+ return { type: "none" };
712
+ }
713
+ /**
714
+ * roving 环绕下标:焦点不在任何行上(activeIndex < 0)时向下从首行进、向上从
715
+ * 末行进;已在某行上时按 offset 循环。length <= 0 返回 -1(无可去之处)。
716
+ */
717
+ function dmsNextRowIndex(activeIndex, offset, length) {
718
+ if (length <= 0) return -1;
719
+ return ((activeIndex < 0 ? offset > 0 ? -1 : 0 : activeIndex) + offset + length) % length;
720
+ }
721
+ //#endregion
722
+ //#region src/client/copy.ts
723
+ /**
724
+ * trigger 文案。目录成员资格只是参考(routable 契约):current 匹配不到任何
725
+ * group 不代表没有选择 —— 此时显示 provider/model 原始 id,而不是「选择模型」。
726
+ */
727
+ function dmsTriggerCopy(input, t) {
728
+ const { current, modelName, providerName, effortLabel, waiting } = input;
729
+ const modelLabel = modelName ?? (waiting ? t("trigger.loading") : current === null ? t("trigger.fallback") : `${current.provider}/${current.model}`);
730
+ const providerLabel = modelName === void 0 ? void 0 : providerName;
731
+ const label = effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`;
732
+ const title = providerLabel === void 0 ? label : `${providerLabel} · ${label}`;
733
+ const named = providerLabel === void 0 ? modelLabel : `${providerLabel} ${modelLabel}`;
734
+ const ariaModel = modelName === void 0 ? current === null ? null : `${current.provider}/${current.model}` : named;
735
+ return {
736
+ modelLabel,
737
+ providerLabel,
738
+ label,
739
+ title,
740
+ aria: waiting ? t("trigger.loading") : ariaModel === null ? t("trigger.selectAria") : effortLabel === void 0 ? t("trigger.aria", { model: ariaModel }) : t("trigger.ariaEffort", {
741
+ model: ariaModel,
742
+ effort: effortLabel
743
+ })
744
+ };
745
+ }
746
+ //#endregion
550
747
  //#region src/client/ModelSelect.tsx
551
748
  /**
552
749
  * ModelSelect: the enhanced composer model seat (`conversation.input.model`).
@@ -563,9 +760,9 @@ window.__ModuleLoader__.load({
563
760
  * reused from @deepseek-ai/dsh-client-ui-primitives at runtime; colors
564
761
  * come from `--dsw-*` tokens in the injected stylesheet.
565
762
  */
566
- const IconChevronDown = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {});
567
- const IconCheck = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline14, {});
568
- const IconClear = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCloseFill14, {});
763
+ const IconChevronDown = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 14 });
764
+ const IconCheck = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, { size: 14 });
765
+ const IconClear = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCloseFillRegular, { size: 14 });
569
766
  /**
570
767
  * How long a successfully loaded directory snapshot is trusted before the
571
768
  * menu re-fetches it over RPC. The snapshot lives in the per-session store,
@@ -582,12 +779,14 @@ window.__ModuleLoader__.load({
582
779
  * 必须释放滑杆的 committing 锁并回滚,否则滑杆被锁死到菜单关闭。
583
780
  * 调法:后端/网络慢时调大避免误回滚(滑杆锁死时间随之变长);调小更早释放交互。 */
584
781
  const EFFORT_COMMIT_TIMEOUT_MS = 12e3;
585
- const EffortSlider = react.memo(function EffortSlider({ state, select, t }) {
782
+ const EffortSlider = react.memo(function EffortSlider({ state, select, t, onSelectFailure }) {
586
783
  const levels = dmsSliderLevels(state);
587
784
  const [effort, setEffort] = react.useState(() => levels[dmsEffectiveEffortIndex(levels, state)]?.id ?? "");
588
785
  const [preview, setPreview] = react.useState(() => dmsEffectiveEffortIndex(levels, state));
589
786
  const [committing, setCommitting] = react.useState(false);
590
787
  const [localError, setLocalError] = react.useState(null);
788
+ const stateRef = react.useRef(state);
789
+ stateRef.current = state;
591
790
  const canvasRef = react.useRef(null);
592
791
  const inputRef = react.useRef(null);
593
792
  const committedRef = react.useRef("");
@@ -616,7 +815,7 @@ window.__ModuleLoader__.load({
616
815
  setLocalError(null);
617
816
  }, [
618
817
  available,
619
- levels.length,
818
+ levels,
620
819
  state.current?.provider,
621
820
  state.current?.model,
622
821
  state.current?.reasoningEffort
@@ -658,7 +857,13 @@ window.__ModuleLoader__.load({
658
857
  if (dmsEffortBusy(committingRef.current, state.status)) return;
659
858
  const index = dmsClampIndex(raw, levels.length);
660
859
  const next = levels[index]?.id;
661
- if (next === void 0 || next === committedRef.current) return;
860
+ if (next === void 0) return;
861
+ if (next === committedRef.current) {
862
+ previewRef.current = index;
863
+ setPreview(index);
864
+ setEffort(next);
865
+ return;
866
+ }
662
867
  committingRef.current = true;
663
868
  const previous = committedRef.current;
664
869
  commitEpochRef.current += 1;
@@ -671,7 +876,8 @@ window.__ModuleLoader__.load({
671
876
  try {
672
877
  const current = state.current;
673
878
  if (current === null) throw new Error(t("empty.efforts"));
674
- if (!await new Promise((resolve, reject) => {
879
+ const modelKeyAtCommit = dmsRowKey(current.provider, current.model);
880
+ const result = await new Promise((resolve, reject) => {
675
881
  let settled = false;
676
882
  const timer = window.setTimeout(() => {
677
883
  if (settled) return;
@@ -684,9 +890,11 @@ window.__ModuleLoader__.load({
684
890
  provider: current.provider,
685
891
  model: current.model,
686
892
  reasoningEffort: next
687
- }).then((accepted) => {
893
+ }).then((result) => {
688
894
  if (settled) {
689
- if (accepted && mountedRef.current && dmsShouldAdoptLateSuccess(committedRef.current, previous, draggingRef.current, committingRef.current, commitEpochRef.current, epochAtCommit)) {
895
+ const latest = stateRef.current.current;
896
+ const sameModel = latest !== null && dmsRowKey(latest.provider, latest.model) === modelKeyAtCommit;
897
+ if (result?.ok === true && mountedRef.current && dmsShouldAdoptLateSuccess(committedRef.current, previous, draggingRef.current, committingRef.current, commitEpochRef.current, epochAtCommit, sameModel)) {
690
898
  committedRef.current = next;
691
899
  previewRef.current = index;
692
900
  setEffort(next);
@@ -698,7 +906,7 @@ window.__ModuleLoader__.load({
698
906
  settled = true;
699
907
  window.clearTimeout(timer);
700
908
  commitTimerRef.current = null;
701
- resolve(accepted);
909
+ resolve(result);
702
910
  }, (cause) => {
703
911
  if (settled) return;
704
912
  settled = true;
@@ -706,7 +914,11 @@ window.__ModuleLoader__.load({
706
914
  commitTimerRef.current = null;
707
915
  reject(cause);
708
916
  });
709
- })) throw new Error(t("effort.failed"));
917
+ });
918
+ if (result === void 0 || !result.ok) {
919
+ if (result !== void 0) console.warn("[dsh-model-selector] effort select failed:", result.error);
920
+ throw new Error(t("effort.failed"));
921
+ }
710
922
  if (mountedRef.current) {
711
923
  committedRef.current = next;
712
924
  previewRef.current = index;
@@ -714,23 +926,29 @@ window.__ModuleLoader__.load({
714
926
  setPreview(index);
715
927
  }
716
928
  } catch (cause) {
717
- const restore = Math.max(0, dmsEffortIndex(levels, previous));
718
- committedRef.current = previous;
719
- previewRef.current = restore;
720
- if (mountedRef.current) {
721
- setEffort(previous);
722
- setPreview(restore);
723
- setLocalError(cause instanceof Error ? cause.message : String(cause));
929
+ if (commitEpochRef.current === epochAtCommit) {
930
+ onSelectFailure();
931
+ const restore = Math.max(0, dmsEffortIndex(levels, previous));
932
+ committedRef.current = previous;
933
+ previewRef.current = restore;
934
+ if (mountedRef.current) {
935
+ setEffort(previous);
936
+ setPreview(restore);
937
+ setLocalError(cause instanceof Error ? cause.message : String(cause));
938
+ }
724
939
  }
725
940
  } finally {
726
- committingRef.current = false;
727
- if (mountedRef.current) setCommitting(false);
941
+ if (commitEpochRef.current === epochAtCommit) {
942
+ committingRef.current = false;
943
+ if (mountedRef.current) setCommitting(false);
944
+ }
728
945
  }
729
946
  }, [
730
947
  levels,
731
948
  select,
732
949
  state,
733
- t
950
+ t,
951
+ onSelectFailure
734
952
  ]);
735
953
  const drag = useEffortDrag({
736
954
  levelCount: () => levels.length,
@@ -818,14 +1036,14 @@ window.__ModuleLoader__.load({
818
1036
  className: "dms-effort-desc",
819
1037
  children: effortDesc
820
1038
  }),
821
- error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1039
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
822
1040
  className: "dms-sr",
823
1041
  role: "status",
824
- children: error
1042
+ children: error ?? ""
825
1043
  }),
826
- error === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1044
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
827
1045
  className: "dms-effort-error",
828
- children: error
1046
+ children: error ?? ""
829
1047
  })
830
1048
  ]
831
1049
  });
@@ -839,7 +1057,7 @@ window.__ModuleLoader__.load({
839
1057
  * 但 100 条命中里绝大多数命中区间不变——按值比较 start/end,值相同即跳过
840
1058
  * 重渲染,搜索才不卡顿。
841
1059
  */
842
- const ModelOption = react.memo(function ModelOption({ group, model, showProvider, selected, busy, rowKey, active, onRowFocus, nameHit, t, onChoose }) {
1060
+ const ModelOption = react.memo(function ModelOption({ group, model, showProvider, selected, busy, rowKey, active, searchMode, onRowFocus, nameHit, setsize, posinset, t, onChoose }) {
843
1061
  const hit = nameHit === void 0 || nameHit === null || nameHit.start === nameHit.end ? null : [
844
1062
  model.name.slice(0, nameHit.start),
845
1063
  model.name.slice(nameHit.start, nameHit.end),
@@ -847,8 +1065,12 @@ window.__ModuleLoader__.load({
847
1065
  ];
848
1066
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
849
1067
  type: "button",
850
- role: "menuitemradio",
851
- "aria-checked": selected,
1068
+ role: searchMode ? "option" : "menuitemradio",
1069
+ "aria-checked": searchMode ? void 0 : selected,
1070
+ "aria-selected": searchMode ? selected : void 0,
1071
+ id: searchMode ? `dms-opt-${encodeURIComponent(rowKey)}` : void 0,
1072
+ "aria-setsize": searchMode && setsize !== void 0 && setsize > posinset ? setsize : void 0,
1073
+ "aria-posinset": searchMode && posinset !== void 0 ? posinset : void 0,
852
1074
  "data-row-key": rowKey,
853
1075
  tabIndex: active ? 0 : -1,
854
1076
  className: `dms-model-option${selected ? " dms-model-optionSelected" : ""}`,
@@ -903,9 +1125,12 @@ window.__ModuleLoader__.load({
903
1125
  if (prev.busy !== next.busy) return false;
904
1126
  if (prev.rowKey !== next.rowKey) return false;
905
1127
  if (prev.active !== next.active) return false;
1128
+ if (prev.searchMode !== next.searchMode) return false;
906
1129
  if (prev.onRowFocus !== next.onRowFocus) return false;
907
1130
  if (prev.t !== next.t) return false;
908
1131
  if (prev.onChoose !== next.onChoose) return false;
1132
+ if (prev.setsize !== next.setsize) return false;
1133
+ if (prev.posinset !== next.posinset) return false;
909
1134
  if (prev.nameHit === next.nameHit) return true;
910
1135
  if (prev.nameHit === null || prev.nameHit === void 0) return false;
911
1136
  if (next.nameHit === null || next.nameHit === void 0) return false;
@@ -932,6 +1157,12 @@ window.__ModuleLoader__.load({
932
1157
  failed
933
1158
  });
934
1159
  }, []);
1160
+ const markSelectFailure = react.useCallback(() => {
1161
+ lastActionRef.current = "select";
1162
+ }, []);
1163
+ const dismissToast = react.useCallback(() => {
1164
+ setToast(null);
1165
+ }, []);
935
1166
  const lastActionRef = react.useRef("load");
936
1167
  const rootRef = react.useRef(null);
937
1168
  const triggerRef = react.useRef(null);
@@ -941,7 +1172,7 @@ window.__ModuleLoader__.load({
941
1172
  const choices = react.useMemo(() => state.groups.flatMap((group) => group.models.map((model) => ({
942
1173
  group,
943
1174
  model,
944
- haystack: `${model.name}\n${model.description ?? ""}\n${group.name}\n${model.id}\n${group.id}`.toLowerCase(),
1175
+ haystack: dmsHaystack(group, model),
945
1176
  selection: {
946
1177
  provider: group.id,
947
1178
  model: model.id,
@@ -957,41 +1188,30 @@ window.__ModuleLoader__.load({
957
1188
  const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort;
958
1189
  const effortLabel = reasoning === void 0 ? void 0 : effectiveEffort === void 0 ? t("effort.providerDefault") : reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort;
959
1190
  const busy = state.status === "selecting";
960
- const normalized = query.trim().toLowerCase();
961
- const hits = react.useMemo(() => {
962
- if (normalized === "") return null;
963
- const items = [];
964
- let total = 0;
965
- for (const choice of choices) {
966
- if (!choice.haystack.includes(normalized)) continue;
967
- total += 1;
968
- if (items.length >= MAX_VISIBLE_HITS) continue;
969
- let nameHit = null;
970
- const at = choice.model.name.toLowerCase().indexOf(normalized);
971
- if (at >= 0) nameHit = {
972
- start: at,
973
- end: at + normalized.length
974
- };
975
- items.push({
976
- group: choice.group,
977
- model: choice.model,
978
- nameHit
979
- });
980
- }
981
- return {
982
- items,
983
- total
984
- };
985
- }, [choices, normalized]);
1191
+ const chooseInputRef = react.useRef({
1192
+ busy,
1193
+ current: state.current,
1194
+ choices,
1195
+ open
1196
+ });
1197
+ chooseInputRef.current = {
1198
+ busy,
1199
+ current: state.current,
1200
+ choices,
1201
+ open
1202
+ };
1203
+ const normalized = dmsNormalizeQuery(query);
1204
+ const hits = react.useMemo(() => dmsSearchHits(choices, normalized, MAX_VISIBLE_HITS), [choices, normalized]);
1205
+ const visibleModelKeys = react.useMemo(() => state.groups.flatMap((g) => collapsed.has(g.id) ? [] : g.models.map((m) => dmsRowKey(g.id, m.id))), [state.groups, collapsed]);
986
1206
  const defaultRowKey = react.useMemo(() => dmsDefaultRowKey({
987
- hitKeys: hits === null ? null : hits.items.map((h) => `${h.group.id}\u0000${h.model.id}`),
988
- modelKeys: state.groups.flatMap((g) => collapsed.has(g.id) ? [] : g.models.map((m) => `${g.id}\u0000${m.id}`)),
989
- headerKeys: state.groups.map((g) => `header:${g.id}`),
990
- selectedKey: state.current === null ? null : `${state.current.provider}\u0000${state.current.model}`
1207
+ hitKeys: hits === null ? null : hits.items.map((h) => dmsRowKey(h.group.id, h.model.id)),
1208
+ modelKeys: visibleModelKeys,
1209
+ headerKeys: state.groups.map((g) => dmsHeaderKey(g.id)),
1210
+ selectedKey: state.current === null ? null : dmsRowKey(state.current.provider, state.current.model)
991
1211
  }), [
992
1212
  hits,
1213
+ visibleModelKeys,
993
1214
  state.groups,
994
- collapsed,
995
1215
  state.current
996
1216
  ]);
997
1217
  react.useEffect(() => {
@@ -1028,7 +1248,9 @@ window.__ModuleLoader__.load({
1028
1248
  setMenuAbove(dmsMenuAbove(rect.top, rect.bottom, window.innerHeight));
1029
1249
  setBelowMaxHeight(dmsBelowMaxHeight(rect.bottom, window.innerHeight, 420));
1030
1250
  const menuWidth = menuRef.current?.offsetWidth ?? 0;
1031
- setMenuLeft(dmsMenuLeft(rect.right, menuWidth, window.innerWidth, 12));
1251
+ const left = dmsMenuLeft(rect.right, menuWidth, window.innerWidth, 12);
1252
+ const rootLeft = rootRef.current?.getBoundingClientRect().left ?? 0;
1253
+ setMenuLeft(left === void 0 ? void 0 : left - rootLeft);
1032
1254
  };
1033
1255
  measure();
1034
1256
  let raf = 0;
@@ -1111,79 +1333,80 @@ window.__ModuleLoader__.load({
1111
1333
  state.groups.length,
1112
1334
  reload
1113
1335
  ]);
1336
+ const focusTrigger = () => {
1337
+ const trigger = triggerRef.current;
1338
+ if (trigger !== null && !trigger.disabled) {
1339
+ trigger.focus();
1340
+ return;
1341
+ }
1342
+ rootRef.current?.focus();
1343
+ };
1114
1344
  const close = react.useCallback((restoreFocus = false) => {
1115
1345
  setOpen(false);
1116
1346
  setNotice(null);
1117
1347
  setQuery("");
1118
- if (restoreFocus) queueMicrotask(() => {
1119
- triggerRef.current?.focus();
1120
- });
1348
+ if (restoreFocus) queueMicrotask(focusTrigger);
1121
1349
  }, []);
1122
1350
  const dismissOnOutsidePointer = react.useCallback(() => {
1123
1351
  close(true);
1124
1352
  }, [close]);
1125
1353
  (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, dismissOnOutsidePointer);
1126
1354
  react.useEffect(() => {
1127
- if (locked && open) close();
1355
+ if (locked && open) close(true);
1128
1356
  }, [
1129
1357
  locked,
1130
1358
  open,
1131
1359
  close
1132
1360
  ]);
1133
1361
  const choose = react.useCallback((selection) => {
1134
- if (busy) return;
1135
- if (state.current?.provider === selection.provider && state.current.model === selection.model) {
1362
+ const { busy: isBusy, current, choices: catalog } = chooseInputRef.current;
1363
+ if (isBusy) return;
1364
+ if (current?.provider === selection.provider && current.model === selection.model) {
1136
1365
  setNotice(t("notice.already"));
1137
1366
  return;
1138
1367
  }
1139
- const target = choices.find((c) => c.selection.provider === selection.provider && c.selection.model === selection.model);
1140
- const max = target?.model.reasoning === void 0 ? void 0 : maxEffortOf(target.model.reasoning);
1141
- const effort = max === "off" ? void 0 : max;
1368
+ const target = catalog.find((c) => c.selection.provider === selection.provider && c.selection.model === selection.model);
1369
+ const plan = dmsChoosePlan(target?.model.reasoning);
1142
1370
  const full = {
1143
1371
  provider: selection.provider,
1144
1372
  model: selection.model,
1145
- ...effort === void 0 ? {} : { reasoningEffort: effort }
1373
+ ...plan.effort === void 0 ? {} : { reasoningEffort: plan.effort }
1146
1374
  };
1147
1375
  lastActionRef.current = "select";
1148
- const autoRaised = effort !== void 0 && effort !== target?.model.reasoning?.defaultEffort;
1149
- const autoName = target?.model.reasoning?.efforts.find((level) => level.id === effort)?.name ?? effort ?? "";
1376
+ const autoName = plan.effort === void 0 ? "" : target?.model.reasoning?.efforts.find((level) => level.id === plan.effort)?.name ?? plan.effort;
1150
1377
  (async () => {
1151
1378
  try {
1152
- if (!await new Promise((resolve, reject) => {
1379
+ const result = await new Promise((resolve, reject) => {
1153
1380
  let settled = false;
1154
1381
  const timer = window.setTimeout(() => {
1155
1382
  if (settled) return;
1156
1383
  settled = true;
1157
1384
  reject(new Error(t("notice.selectFailed")));
1158
1385
  }, EFFORT_COMMIT_TIMEOUT_MS);
1159
- select(full).then((ok) => {
1386
+ select(full).then((settledResult) => {
1160
1387
  if (settled) return;
1161
1388
  settled = true;
1162
1389
  window.clearTimeout(timer);
1163
- resolve(ok);
1390
+ resolve(settledResult);
1164
1391
  }, (cause) => {
1165
1392
  if (settled) return;
1166
1393
  settled = true;
1167
1394
  window.clearTimeout(timer);
1168
1395
  reject(cause);
1169
1396
  });
1170
- })) {
1171
- const message = directory.getSnapshot().error;
1397
+ });
1398
+ if (result === void 0 || !result.ok) {
1399
+ const message = result !== void 0 ? `${result.error.code}: ${result.error.message}` : directory.getSnapshot().error;
1172
1400
  showToast(message !== null ? t("error.action", { message }) : t("notice.selectFailed"));
1173
1401
  return;
1174
1402
  }
1175
- if (rootRef.current !== null) {
1176
- close(true);
1177
- if (autoRaised) showToast(t("toast.effortAuto", { effort: autoName }), false);
1178
- }
1403
+ if (chooseInputRef.current.open) close(true);
1404
+ if (plan.autoRaised) showToast(t("toast.effortAuto", { effort: autoName }), false);
1179
1405
  } catch {
1180
1406
  showToast(t("notice.selectFailed"));
1181
1407
  }
1182
1408
  })();
1183
1409
  }, [
1184
- busy,
1185
- state.current,
1186
- choices,
1187
1410
  select,
1188
1411
  t,
1189
1412
  directory,
@@ -1204,8 +1427,7 @@ window.__ModuleLoader__.load({
1204
1427
  if (menu === null) return;
1205
1428
  const items = [...menu.querySelectorAll("[data-row-key]")];
1206
1429
  if (items.length === 0) return;
1207
- const active = items.findIndex((item) => item === document.activeElement);
1208
- items[((active < 0 ? offset > 0 ? -1 : 0 : active) + offset + items.length) % items.length]?.focus();
1430
+ items[dmsNextRowIndex(items.findIndex((item) => item === document.activeElement), offset, items.length)]?.focus();
1209
1431
  };
1210
1432
  const focusEdge = (last) => {
1211
1433
  const items = menuRef.current?.querySelectorAll("[data-row-key]");
@@ -1213,65 +1435,70 @@ window.__ModuleLoader__.load({
1213
1435
  (last ? items[items.length - 1] : items[0])?.focus();
1214
1436
  };
1215
1437
  const onRootKeyDown = (event) => {
1216
- if (event.nativeEvent.isComposing) return;
1217
1438
  const target = event.target;
1218
- if (event.key === "Escape" && open) {
1219
- if (query !== "" && rootRef.current?.contains(document.activeElement) === true) {
1220
- const fromSearch = target instanceof HTMLInputElement && target === searchRef.current;
1439
+ const action = dmsMenuKeyAction({
1440
+ key: event.key,
1441
+ composing: event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229,
1442
+ open,
1443
+ fromSearch: target instanceof HTMLInputElement && target === searchRef.current,
1444
+ inMenu: menuRef.current?.contains(document.activeElement) === true,
1445
+ onNonInput: !(target instanceof HTMLInputElement),
1446
+ hasQuery: query !== "",
1447
+ rowCount: menuRef.current?.querySelectorAll("[data-row-key]").length ?? 0,
1448
+ hitCount: hits?.items.length ?? 0
1449
+ });
1450
+ switch (action.type) {
1451
+ case "none": return;
1452
+ case "clearQuery":
1453
+ event.preventDefault();
1221
1454
  setNotice(null);
1222
1455
  setQuery("");
1223
- if (!fromSearch) searchRef.current?.focus();
1456
+ if (action.focusSearch) searchRef.current?.focus();
1224
1457
  return;
1225
- }
1226
- event.preventDefault();
1227
- close(true);
1228
- return;
1229
- }
1230
- if (!open) return;
1231
- const fromSearch = target instanceof HTMLInputElement && target === searchRef.current;
1232
- if ((event.key === "ArrowDown" || event.key === "ArrowUp") && (!(target instanceof HTMLInputElement) || fromSearch)) {
1233
- event.preventDefault();
1234
- moveFocus(event.key === "ArrowDown" ? 1 : -1);
1235
- return;
1236
- }
1237
- if ((event.key === "Home" || event.key === "End") && !(target instanceof HTMLInputElement)) {
1238
- event.preventDefault();
1239
- focusEdge(event.key === "End");
1240
- return;
1241
- }
1242
- if (event.key === "Enter" && fromSearch) {
1243
- if (hits !== null && hits.items.length > 0) {
1458
+ case "close":
1459
+ event.preventDefault();
1460
+ close(true);
1461
+ return;
1462
+ case "moveFocus":
1463
+ event.preventDefault();
1464
+ moveFocus(action.offset);
1465
+ return;
1466
+ case "focusEdge":
1467
+ event.preventDefault();
1468
+ focusEdge(action.last);
1469
+ return;
1470
+ case "chooseFirstHit": {
1471
+ const first = hits?.items[0];
1472
+ if (first === void 0) return;
1244
1473
  event.preventDefault();
1245
- const first = hits.items[0];
1246
1474
  choose({
1247
1475
  provider: first.group.id,
1248
1476
  model: first.model.id
1249
1477
  });
1478
+ return;
1250
1479
  }
1251
1480
  }
1252
1481
  };
1253
1482
  const onBlur = (event) => {
1254
- if (busy) return;
1255
1483
  const related = event.relatedTarget;
1484
+ if (related instanceof Node && rootRef.current?.contains(related) === true) return;
1256
1485
  if (related instanceof Node) {
1257
- if (rootRef.current?.contains(related)) return;
1258
1486
  close();
1259
1487
  return;
1260
1488
  }
1261
- close();
1489
+ if (document.visibilityState === "hidden") close();
1262
1490
  };
1263
- const waiting = state.current === null && state.status === "loading";
1264
- const modelLabel = currentChoice?.model.name ?? (waiting ? t("trigger.loading") : state.current === null ? t("trigger.fallback") : `${state.current.provider}/${state.current.model}`);
1265
- const providerLabel = currentChoice?.group.name;
1266
- const triggerLabel = effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`;
1267
- const triggerTitle = providerLabel === void 0 ? triggerLabel : `${providerLabel} · ${triggerLabel}`;
1268
- const triggerAria = waiting ? t("trigger.loading") : currentChoice === void 0 ? state.current === null ? t("trigger.selectAria") : t("trigger.aria", { model: `${state.current.provider}/${state.current.model}` }) : effortLabel === void 0 ? t("trigger.aria", { model: providerLabel === void 0 ? modelLabel : `${providerLabel} ${modelLabel}` }) : t("trigger.ariaEffort", {
1269
- model: providerLabel === void 0 ? modelLabel : `${providerLabel} ${modelLabel}`,
1270
- effort: effortLabel
1271
- });
1491
+ const trigger = dmsTriggerCopy({
1492
+ current: state.current,
1493
+ modelName: currentChoice?.model.name,
1494
+ providerName: currentChoice?.group.name,
1495
+ effortLabel,
1496
+ waiting: state.current === null && state.status === "loading"
1497
+ }, t);
1272
1498
  const renderErrorStrip = () => {
1273
1499
  if (state.error !== null && lastActionRef.current === "load") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1274
1500
  className: "dms-error",
1501
+ role: "alert",
1275
1502
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("error.action", { message: state.error }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1276
1503
  type: "button",
1277
1504
  className: "dms-retry",
@@ -1293,11 +1520,16 @@ window.__ModuleLoader__.load({
1293
1520
  children: t("action.reload")
1294
1521
  })]
1295
1522
  }, failure.id));
1296
- const noHits = hits !== null && hits.items.length === 0;
1297
- const noModels = hits === null && state.status === "ready" && choices.length === 0;
1523
+ const emptyState = dmsMenuEmptyState({
1524
+ status: state.status,
1525
+ choiceCount: choices.length,
1526
+ searching: hits !== null,
1527
+ hitCount: hits?.total ?? 0
1528
+ });
1298
1529
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1299
1530
  ref: rootRef,
1300
1531
  className: "dms-root",
1532
+ tabIndex: -1,
1301
1533
  onKeyDown: onRootKeyDown,
1302
1534
  onBlur,
1303
1535
  children: [
@@ -1305,21 +1537,21 @@ window.__ModuleLoader__.load({
1305
1537
  ref: triggerRef,
1306
1538
  type: "button",
1307
1539
  className: "dms-trigger",
1308
- "aria-label": triggerAria,
1540
+ "aria-label": trigger.aria,
1309
1541
  "aria-haspopup": "menu",
1310
1542
  "aria-expanded": open,
1311
1543
  "aria-controls": open ? `${id}-menu` : void 0,
1312
- title: triggerTitle,
1544
+ title: trigger.title,
1313
1545
  disabled: locked,
1314
1546
  onClick: () => open ? close() : show(),
1315
1547
  children: [
1316
1548
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1317
1549
  className: "dms-triggerLabel",
1318
- children: modelLabel
1550
+ children: trigger.modelLabel
1319
1551
  }),
1320
- providerLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1552
+ trigger.providerLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1321
1553
  className: "dms-triggerProvider",
1322
- children: providerLabel
1554
+ children: trigger.providerLabel
1323
1555
  }),
1324
1556
  effortLabel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1325
1557
  className: "dms-triggerEffort",
@@ -1343,15 +1575,16 @@ window.__ModuleLoader__.load({
1343
1575
  }
1344
1576
  },
1345
1577
  className: "dms-menu dms-menuModel" + (menuAbove ? "" : " dms-menuBelow"),
1346
- "aria-busy": state.status === "loading" || busy,
1347
1578
  children: [
1348
1579
  state.status === "loading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1349
1580
  className: "dms-status",
1581
+ role: "status",
1350
1582
  children: t("status.loading")
1351
1583
  }),
1352
1584
  renderErrorStrip(),
1353
1585
  state.failures.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1354
1586
  className: "dms-failures",
1587
+ role: "alert",
1355
1588
  children: renderFailures(state.failures)
1356
1589
  }),
1357
1590
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1363,7 +1596,10 @@ window.__ModuleLoader__.load({
1363
1596
  value: query,
1364
1597
  placeholder: t("search.placeholder"),
1365
1598
  "aria-label": t("search.placeholder"),
1366
- "aria-controls": `${id}-groups`,
1599
+ role: "combobox",
1600
+ "aria-expanded": hits !== null && hits.total > 0,
1601
+ "aria-autocomplete": "list",
1602
+ "aria-controls": emptyState === null ? `${id}-groups` : void 0,
1367
1603
  autoComplete: "off",
1368
1604
  enterKeyHint: "search",
1369
1605
  spellCheck: false,
@@ -1387,28 +1623,34 @@ window.__ModuleLoader__.load({
1387
1623
  })
1388
1624
  })]
1389
1625
  }),
1390
- hits !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1626
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1391
1627
  className: "dms-sr",
1392
1628
  role: "status",
1393
- children: t("search.status", { count: String(hits.total) })
1629
+ children: hits !== null && hits.total > 0 ? t("search.status", { count: String(hits.total) }) : ""
1630
+ }),
1631
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1632
+ className: "dms-sr",
1633
+ role: "note",
1634
+ children: t("badge.reasoningHint")
1394
1635
  }),
1395
- noHits && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1636
+ emptyState === "noHits" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1396
1637
  className: "dms-empty dms-groupsFill",
1397
1638
  role: "status",
1398
1639
  children: t("search.noMatch", { query: query.trim() })
1399
1640
  }),
1400
- noModels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1641
+ emptyState === "noModels" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1401
1642
  className: "dms-empty dms-groupsFill",
1402
1643
  role: "status",
1403
1644
  children: t("empty.models")
1404
1645
  }),
1405
- !noHits && !noModels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1646
+ emptyState === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1406
1647
  className: "dms-groups",
1407
1648
  id: `${id}-groups`,
1408
- role: "menu",
1649
+ role: hits !== null ? "listbox" : "menu",
1650
+ "aria-busy": state.status === "loading" || busy,
1409
1651
  "aria-label": t("menu.aria"),
1410
- children: hits !== null ? hits.items.map((hit) => {
1411
- const rowKey = `${hit.group.id}\u0000${hit.model.id}`;
1652
+ children: hits !== null ? hits.items.map((hit, index) => {
1653
+ const rowKey = dmsRowKey(hit.group.id, hit.model.id);
1412
1654
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOption, {
1413
1655
  group: hit.group,
1414
1656
  model: hit.model,
@@ -1417,13 +1659,17 @@ window.__ModuleLoader__.load({
1417
1659
  busy,
1418
1660
  rowKey,
1419
1661
  active: rowKey === activeRowKey,
1662
+ searchMode: true,
1420
1663
  onRowFocus,
1421
1664
  nameHit: hit.nameHit,
1665
+ setsize: hits.total,
1666
+ posinset: index + 1,
1422
1667
  t,
1423
1668
  onChoose: choose
1424
1669
  }, rowKey);
1425
1670
  }) : state.groups.map((group) => {
1426
1671
  const headingId = `${id}-${group.id}`;
1672
+ const headerKey = dmsHeaderKey(group.id);
1427
1673
  const isCollapsed = collapsed.has(group.id);
1428
1674
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1429
1675
  "data-group-id": group.id,
@@ -1433,14 +1679,15 @@ window.__ModuleLoader__.load({
1433
1679
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1434
1680
  type: "button",
1435
1681
  role: "menuitem",
1436
- "data-row-key": `header:${group.id}`,
1437
- tabIndex: -1,
1682
+ "data-row-key": headerKey,
1683
+ tabIndex: activeRowKey === headerKey ? 0 : -1,
1438
1684
  className: "dms-groupHeader",
1439
1685
  "aria-expanded": !isCollapsed,
1440
1686
  "aria-label": t("group.toggleAria", {
1441
1687
  name: group.name,
1442
1688
  count: String(group.models.length)
1443
1689
  }),
1690
+ onFocus: () => onRowFocus(headerKey),
1444
1691
  onClick: () => toggleCollapse(group.id),
1445
1692
  children: [
1446
1693
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -1459,7 +1706,7 @@ window.__ModuleLoader__.load({
1459
1706
  })
1460
1707
  ]
1461
1708
  }), !isCollapsed && group.models.map((model) => {
1462
- const rowKey = `${group.id}\u0000${model.id}`;
1709
+ const rowKey = dmsRowKey(group.id, model.id);
1463
1710
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelOption, {
1464
1711
  group,
1465
1712
  model,
@@ -1468,6 +1715,7 @@ window.__ModuleLoader__.load({
1468
1715
  busy,
1469
1716
  rowKey,
1470
1717
  active: rowKey === activeRowKey,
1718
+ searchMode: false,
1471
1719
  onRowFocus,
1472
1720
  t,
1473
1721
  onChoose: choose
@@ -1492,23 +1740,22 @@ window.__ModuleLoader__.load({
1492
1740
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EffortSlider, {
1493
1741
  state,
1494
1742
  select,
1495
- t
1743
+ t,
1744
+ onSelectFailure: markSelectFailure
1496
1745
  })]
1497
1746
  }),
1498
- notice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1747
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1499
1748
  className: "dms-notice",
1500
1749
  role: "status",
1501
- children: notice
1750
+ children: notice ?? ""
1502
1751
  })
1503
1752
  ]
1504
1753
  }) : null,
1505
1754
  toast !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Toast, {
1506
1755
  text: toast.text,
1507
- icon: toast.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {}) : void 0,
1756
+ icon: toast.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutlineRegular, { size: 14 }) : void 0,
1508
1757
  anchor: rootRef.current?.closest("[data-composer-card]") ?? null,
1509
- onDone: () => {
1510
- setToast(null);
1511
- }
1758
+ onDone: dismissToast
1512
1759
  }, toast.seq)
1513
1760
  ]
1514
1761
  });
@@ -1522,12 +1769,18 @@ window.__ModuleLoader__.load({
1522
1769
  * apply and removed again on unload. Class names are prefixed `dms-` so they
1523
1770
  * cannot collide with CSS-module hashes from other plugins. Colors come only
1524
1771
  * from `--dsw-*` theme tokens, matching the shipped Menu material.
1772
+ *
1773
+ * 显式 `: string` 注解:不加注解时 tsc 会把整份 CSS 推断成字面量类型并写进
1774
+ * lib/types/client/styles.d.ts(19KB 的 d.ts 只为声明一个常量),发布包里
1775
+ * 白白多出十几 KB 且每次改样式都全量重写。
1525
1776
  */
1526
1777
  const CSS = `
1527
1778
  .dms-root {
1528
1779
  position: relative;
1529
1780
  min-width: 0;
1530
1781
  }
1782
+ /* root 只作为焦点落点(tabIndex=-1):不画焦点环,真正的可聚焦控件在它内部。 */
1783
+ .dms-root:focus { outline: none; }
1531
1784
 
1532
1785
  .dms-trigger {
1533
1786
  display: flex;
@@ -1622,9 +1875,20 @@ window.__ModuleLoader__.load({
1622
1875
  animation-name: dms-menu-in-below;
1623
1876
  }
1624
1877
 
1878
+ /* 菜单头部的「非列表」栏(loading / 空态 / 错误条 / 失败条 / 截断提示):
1879
+ 一律允许收缩(flex-shrink 1)且各自限高,让 .dms-groups 始终保住可滚动
1880
+ 的最小可视高度。2026-09-21 修复的 P0:向上弹时 useAnchoredMaxHeight 会把
1881
+ maxHeight 钳到「trigger 底缘 - 12px」(底缘固定、向上生长的语义),trigger
1882
+ 靠近视口顶部时该值很小;原先这些栏是 flex: 0 0 auto 完全不收缩,头部
1883
+ 40-100px 全部占完后 .dms-groups 的可视高度趋近 0,而 .dms-menu 是
1884
+ overflow:hidden —— 用户看到错误条却看不到也滚不到任何模型行。
1885
+ 现在给它们统一的上限(相对菜单高度)+ overflow,最坏情况是栏内自己滚动。 */
1625
1886
  .dms-status,
1626
1887
  .dms-empty {
1627
- flex: 0 0 auto;
1888
+ flex: 0 1 auto;
1889
+ min-height: 0;
1890
+ max-height: 40%;
1891
+ overflow-y: auto;
1628
1892
  padding: 10px;
1629
1893
  color: var(--dsw-alias-label-tertiary);
1630
1894
  font-size: 13px;
@@ -1632,7 +1896,10 @@ window.__ModuleLoader__.load({
1632
1896
  }
1633
1897
 
1634
1898
  .dms-more {
1635
- flex: 0 0 auto;
1899
+ flex: 0 1 auto;
1900
+ min-height: 0;
1901
+ max-height: 20%;
1902
+ overflow-y: auto;
1636
1903
  padding: 8px 12px;
1637
1904
  border-top: 1px solid var(--dsw-alias-border-l1, transparent);
1638
1905
  color: var(--dsw-alias-label-tertiary);
@@ -1643,15 +1910,18 @@ window.__ModuleLoader__.load({
1643
1910
  /* Provider-load failure strip: capped and scrollable so a long list of failed
1644
1911
  groups can never squeeze the model list out of the menu (E4). */
1645
1912
  .dms-failures {
1646
- flex: 0 0 auto;
1647
- max-height: 96px;
1913
+ flex: 0 1 auto;
1914
+ max-height: min(96px, 30%);
1648
1915
  min-height: 0;
1649
1916
  overflow-y: auto;
1650
1917
  }
1651
1918
 
1652
1919
  .dms-error,
1653
1920
  .dms-warning {
1654
- flex: 0 0 auto;
1921
+ flex: 0 1 auto;
1922
+ min-height: 0;
1923
+ max-height: 40%;
1924
+ overflow-y: auto;
1655
1925
  display: flex;
1656
1926
  align-items: flex-start;
1657
1927
  justify-content: space-between;
@@ -1726,7 +1996,10 @@ window.__ModuleLoader__.load({
1726
1996
  /* 图标组件只收 size/className,装饰性 aria-hidden 只能包一层 span;contents 让它不占布局。 */
1727
1997
  .dms-icon-slot { display: contents; }
1728
1998
 
1729
- .dms-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; contain: content; }
1999
+ /* 列表区:菜单里唯一的弹性主体。max-height 被 useAnchoredMaxHeight 钳得很小
2000
+ (trigger 靠近视口顶 + 向上弹)时,32px 保底让「至少见到一行模型」始终成立
2001
+ —— 头部各栏此时按自己的 max-height 百分比上限收缩(见 .dms-status 等)。 */
2002
+ .dms-groups { flex: 1 1 auto; min-height: 32px; overflow-y: auto; contain: content; }
1730
2003
  /* 空态占位:空态播报节点在 role=menu 容器外,用与 .dms-groups 同款弹性布局
1731
2004
  补回列表区本来的位置(不挤压其余栏,消息也不会贴到菜单顶部)。 */
1732
2005
  .dms-groupsFill { flex: 1 1 auto; min-height: 0; overflow-y: auto; contain: content; }
@@ -1789,6 +2062,8 @@ window.__ModuleLoader__.load({
1789
2062
  font-size: 12px;
1790
2063
  line-height: 18px;
1791
2064
  }
2065
+ /* 常驻 live region(内容随状态更新):空时收起,不占位。 */
2066
+ .dms-notice:empty { display: none; }
1792
2067
  .dms-effort {
1793
2068
  display: flex;
1794
2069
  align-items: center;
@@ -1959,6 +2234,8 @@ window.__ModuleLoader__.load({
1959
2234
  }
1960
2235
  .dms-effort.is-busy { opacity: .72; }
1961
2236
  .dms-effort-error { flex: 1 0 100%; margin-top: 8px; padding: 6px 10px; border-radius: 8px; color: var(--dsw-alias-state-error-primary, #c83e4d); background: var(--dsw-alias-state-error-tertiary, rgba(220,55,70,.08)); font-size: 11px; line-height: 1.5; }
2237
+ /* 常驻容器(内容随状态更新):空时收起,不占位。 */
2238
+ .dms-effort-error:empty { display: none; }
1962
2239
  /* 视觉隐藏工具类(SR 播报/错误原文:不占布局但可被读屏读到)。 */
1963
2240
  .dms-sr {
1964
2241
  position: absolute;
@@ -1999,14 +2276,21 @@ window.__ModuleLoader__.load({
1999
2276
  }
2000
2277
  .dms-model-option-desc { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary, #9296a0); font-size: 10px; }
2001
2278
  .dms-model-check { color: var(--dsw-static-deepseek-500, #4d70ff); font-size: 15px; text-align: center; }
2002
- /* 方向键导航把焦点落在选项上,此前没有任何焦点指示。 */
2003
- .dms-model-option:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
2279
+ /* 方向键导航把焦点落在选项上。选中态是「背景 + 加粗 + 勾号」,悬停是「背景」,
2280
+ 两者与焦点指示必须两两可辨 —— 只用同一个 hover 背景 token 会让「焦点落在
2281
+ 选中行上」完全不可见(roving 的默认落点恰恰优先选中行)。 */
2282
+ .dms-model-option:focus-visible {
2283
+ background: var(--dsw-alias-interactive-bg-hover);
2284
+ box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
2285
+ }
2004
2286
  /* 当前选中行:仅靠勾号不足以在密集列表里定位。 */
2005
2287
  .dms-model-optionSelected { background: var(--dsw-alias-interactive-bg-hover); }
2006
2288
  .dms-model-optionSelected .dms-model-option-name { font-weight: 600; }
2007
2289
  /* select 进行中:官方以 disabled 变灰表达,这里用 aria-busy 驱动同样的视觉、
2008
- 不夺键盘焦点(aria-disabled 保连续性,见 ModelOption)。 */
2009
- .dms-menu[aria-busy="true"] .dms-model-option { color: var(--dsw-alias-label-dimmed); }
2290
+ 不夺键盘焦点(aria-disabled 保连续性,见 ModelOption)。aria-busy 挂在
2291
+ role=menu/listbox 容器上(.dms-groups)—— 挂在外层 .dms-menu 会罩住
2292
+ live region、压制其播报。 */
2293
+ .dms-groups[aria-busy="true"] .dms-model-option { color: var(--dsw-alias-label-dimmed); }
2010
2294
  /* 搜索结果里的供应商标是区分跨供应商同名模型的唯一线索,之前无任何样式。 */
2011
2295
  .dms-model-option-provider {
2012
2296
  display: block;
@@ -2118,6 +2402,14 @@ body:not([data-ds-dark-theme]) .dms-effort.is-dragging .dms-effort-knob {
2118
2402
  //#region src/client/index.ts
2119
2403
  /** Dictionary namespace owned by this plugin. */
2120
2404
  const NS = "modelSelector";
2405
+ /**
2406
+ * 座位优先级。ui-conversation 把 `conversation.input.model` 声明为 kind 'single'
2407
+ * (单槽按 priority 升序取最低者渲染),官方占用者
2408
+ * (@deepseek-ai/dsh-client-ui-model-selection)注册时不传 priority(即默认 0),
2409
+ * 故 -1 恒胜出。**改这个值等于静默换回官方 UI** —— test/plugin-contract.test.mjs
2410
+ * 直接断言本常量(而不是扫源码文本,那样一行注释就能骗过它),注册处也不许再写裸字面量。
2411
+ */
2412
+ const SEAT_PRIORITY = -1;
2121
2413
  /** Required services: the registry, session lookup, locale, the slot seat, and the model directory's Remote faces. */
2122
2414
  const inject = [
2123
2415
  "slots",
@@ -2165,20 +2457,18 @@ body:not([data-ds-dark-theme]) .dms-effort.is-dragging .dms-effort-knob {
2165
2457
  console.warn("[dsh-model-selector] directory.load failed:", error);
2166
2458
  });
2167
2459
  },
2168
- select: (selection) => available ? directory.select(selection).then(() => true, (error) => {
2169
- console.warn("[dsh-model-selector] select failed:", error);
2170
- return false;
2171
- }) : Promise.resolve(false)
2460
+ select: (selection) => available ? directory.select(selection) : Promise.resolve(void 0)
2172
2461
  };
2173
2462
  }
2174
2463
  }, ModelSelect));
2175
2464
  });
2176
2465
  }
2177
2466
  //#endregion
2467
+ exports.SEAT_PRIORITY = SEAT_PRIORITY;
2178
2468
  exports.apply = apply;
2179
2469
  exports.inject = inject;
2180
2470
  return module.exports;
2181
- }
2471
+ /* __DMS_SRC_HASH__ = "86fa33a484beb4717e3ba62f62d7834bd0be9438ebf41565218cd94a6da91e19" */ }
2182
2472
  });
2183
2473
 
2184
2474
  //# sourceMappingURL=client.js.map