@deepseek-ai/dsh-client-ui-model-selection 0.1.5-rc.2 → 0.1.6-alpha.2

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
5
- README.md: ae939ce678bcc50aa69c6c260f1782830eea103f
6
- README.zh.md: d9826f55a48f141f8baa65f230e24ea8d0254e30
5
+ README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
6
+ README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d
package/README.md CHANGED
@@ -25,7 +25,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses
25
25
  <a id="use-this-package"></a>
26
26
  ## Use this package
27
27
 
28
- Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
28
+ Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
29
29
 
30
30
  ### Model and effort
31
31
 
@@ -35,6 +35,10 @@ Models stay grouped by provider. The composer menu shows model and effort names
35
35
 
36
36
  When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable.
37
37
 
38
+ ### Selection failures
39
+
40
+ When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
41
+
38
42
  -----
39
43
 
40
44
  <a id="understand-the-implementation"></a>
@@ -43,7 +47,7 @@ When the Host reports that no adapter serves the session's route, this plugin ra
43
47
  <details>
44
48
  <summary>Implementation internals — click to expand</summary>
45
49
 
46
- Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events.
50
+ Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events.
47
51
 
48
52
  </details>
49
53
 
@@ -91,4 +95,4 @@ None.
91
95
 
92
96
  </details>
93
97
 
94
- **Runtime invariant:** No companion is published. A single command contribution registration whose disposal is proven by the HMR-safety spec — it emits no cordis events and owns no cross-plugin mutable state.
98
+ **Runtime invariant:** No companion is published. The plugin registers a single command contribution, and the HMR-safety spec proves that the registration is disposed correctly. The plugin emits no Cordis events and owns no cross-plugin mutable state.
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。如果没有适配器可以服务会话路由,composer 会保持停用,直至路由恢复可用。
12
+ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。如果没有适配器可以服务会话路由,composer 会保持停用,直至路由恢复可用。
13
13
 
14
14
  ## 目录
15
15
 
@@ -25,7 +25,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
25
25
  <a id="use-this-package"></a>
26
26
  ## 使用本包
27
27
 
28
- 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。当确切提供方/模型对仍在已公布分组中时,两个表面都显示宿主报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
28
+ 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
29
29
 
30
30
  ### 模型与推理强度
31
31
 
@@ -33,7 +33,11 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
33
33
 
34
34
  ### 不可路由的会话
35
35
 
36
- 当宿主报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入随本插件自己的文案停用;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
36
+ 当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
37
+
38
+ ### 选择失败
39
+
40
+ 当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
37
41
 
38
42
  -----
39
43
 
@@ -43,7 +47,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
43
47
  <details>
44
48
  <summary>实现细节——点击展开</summary>
45
49
 
46
- 两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的建议目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一人口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取宿主恢复的选择。目录按会话惰性解析,随会话作用域一并释放;已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated` 与 `settings/document-updated` owner 事件上重拉。
50
+ 两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的建议目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated` 与 `settings/document-updated` owner 事件上重拉。
47
51
 
48
52
  </details>
49
53
 
@@ -52,7 +56,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
52
56
  <a id="further-exploration"></a>
53
57
  ## 进一步探索
54
58
 
55
- 当模型面不够用时阅读以下页面。它们从浏览器表面进入命令弹窗外壳与选择约定。
59
+ 当仅了解模型界面还不够时,请阅读以下页面。这些页面从浏览器界面逐步深入到命令弹窗外壳与选择约定。
56
60
 
57
61
  - [ui-commands](../ui-commands/README.zh.md)——`/model` 贡献项注册进的 popupSelect 外壳。
58
62
  - [ui-conversation](../ui-conversation/README.zh.md)——声明 composer 的 `conversation.input.model` 位与 composer 阻塞块。
@@ -64,7 +68,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
64
68
  <a id="model-experience"></a>
65
69
  ## 模型体验
66
70
 
67
- 间接影响。两个入口都提交 `session.selectModel` 选择;宿主在下一次提示词组装边界对完整 `ModelSelection` 快照并拥有模型可见效果,而运行中的步骤保留已组装选择。
71
+ 两个入口提交的 `session.selectModel` 选择会间接影响模型:Host 会在下一次提示词组装边界为完整的 `ModelSelection` 创建快照,并负责使其对模型生效;运行中的步骤则保留已组装的选择。
68
72
 
69
73
  #### KV Cache 影响
70
74
 
@@ -75,9 +79,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
75
79
  <a id="known-limitations-and-deferred-work"></a>
76
80
 
77
81
 
78
- 这些限制界定了当前模型表面。它们是当前包约束,不是通用模型路由器对比或任务积压。
82
+ 这些限制界定了当前模型选择界面。它们是当前包约束,不是通用模型路由器对比或任务积压。
79
83
 
80
- - **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
84
+ - **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent(智能体);没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
81
85
  - **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
82
86
  - **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
83
87
 
@@ -91,4 +95,4 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
91
95
 
92
96
  </details>
93
97
 
94
- **运行时不变式:** 不发布伴生入口。插件只注册一个 command contribution,HMR 测试覆盖释放;它不发出 Cordis 事件,也不持有跨插件可变状态。
98
+ **运行时不变式:** 不发布伴生入口。插件只注册一个 command contribution,HMR(热模块替换)安全性测试证明该注册的 dispose 能正确完成;它不发出 Cordis 事件,也不持有跨插件可变状态。
package/lib/client.js CHANGED
@@ -4,12 +4,75 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
7
8
  let _deepseek_ai_cordis = require("@deepseek-ai/cordis");
8
9
  let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
9
10
  let react_jsx_runtime = require("react/jsx-runtime");
10
11
  let react = require("react");
11
12
  let react_dom = require("react-dom");
12
- let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
13
+ //#region ../../util/values/src/index.ts
14
+ /**
15
+ * Weak-key lookup with a strongly retained iterable set of associated values.
16
+ *
17
+ * Each value must belong to only one key. The container performs no automatic
18
+ * cleanup; owners delete associations or clear the container at lifecycle end.
19
+ */
20
+ var WeakMapWithValues = class {
21
+ keys = /* @__PURE__ */ new WeakMap();
22
+ valueSet = /* @__PURE__ */ new Set();
23
+ /** Live strongly retained values in insertion order. */
24
+ values = this.valueSet;
25
+ /**
26
+ * Read the value associated with a key.
27
+ * @param key - weakly held lookup key.
28
+ * @returns the associated value, or absence.
29
+ */
30
+ get(key) {
31
+ return this.keys.get(key);
32
+ }
33
+ /**
34
+ * Test whether a key has an association.
35
+ * @param key - weakly held lookup key.
36
+ * @returns whether the key is present.
37
+ */
38
+ has(key) {
39
+ return this.keys.has(key);
40
+ }
41
+ /**
42
+ * Associate one key with one caller-unique value.
43
+ * @param key - weakly held lookup key.
44
+ * @param value - strongly retained value that belongs to no other key.
45
+ * @returns this container.
46
+ */
47
+ set(key, value) {
48
+ if (this.keys.has(key)) {
49
+ const previous = this.keys.get(key);
50
+ if (previous === value) return this;
51
+ this.valueSet.delete(previous);
52
+ }
53
+ this.keys.set(key, value);
54
+ this.valueSet.add(value);
55
+ return this;
56
+ }
57
+ /**
58
+ * Remove one association and its strongly retained value.
59
+ * @param key - weakly held lookup key.
60
+ * @returns whether an association was removed.
61
+ */
62
+ delete(key) {
63
+ if (!this.keys.has(key)) return false;
64
+ const value = this.keys.get(key);
65
+ const deleted = this.keys.delete(key);
66
+ this.valueSet.delete(value);
67
+ return deleted;
68
+ }
69
+ /** Remove every association and strongly retained value. */
70
+ clear() {
71
+ this.keys = /* @__PURE__ */ new WeakMap();
72
+ this.valueSet.clear();
73
+ }
74
+ };
75
+ //#endregion
13
76
  //#region lib/types/client/catalog.js
14
77
  /** One Host-generation model catalog shared by every Session selector. */
15
78
  /** Loads at most one model catalog for the current Host generation. */
@@ -146,8 +209,9 @@ window.__ModuleLoader__.load({
146
209
  /**
147
210
  * Select the complete provider/model/reasoning selection. The durable
148
211
  * projection frame updates the shared current; failures surface on the store
149
- * and throw so each entry's own retry surface engages.
212
+ * and return with the operation so each entry can present its own failure.
150
213
  * @param selection - provider, provider-owned model id, and optional adapter-owned effort.
214
+ * @returns the selection outcome, including the original Remote failure.
151
215
  */
152
216
  async select(selection) {
153
217
  this.assertAvailable();
@@ -162,22 +226,26 @@ window.__ModuleLoader__.load({
162
226
  model: selection.model,
163
227
  ...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort }
164
228
  });
165
- if (this.disposed || generation !== this.generation) {
166
- if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
167
- return;
168
- }
229
+ if (this.disposed || generation !== this.generation) return result.ok ? {
230
+ ok: true,
231
+ value: void 0
232
+ } : result;
169
233
  if (!result.ok) {
170
234
  this.store.update((s) => {
171
235
  s.status = "error";
172
236
  s.error = `${result.error.code}: ${result.error.message}`;
173
237
  });
174
- throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`);
238
+ return result;
175
239
  }
176
240
  this.store.update((s) => {
177
241
  s.status = "ready";
178
242
  s.error = null;
179
243
  });
180
244
  this.syncInputs();
245
+ return {
246
+ ok: true,
247
+ value: void 0
248
+ };
181
249
  }
182
250
  /**
183
251
  * Invalidate an in-flight selection response from the previous Host generation.
@@ -260,7 +328,7 @@ window.__ModuleLoader__.load({
260
328
  "remote",
261
329
  "remote.session"
262
330
  ];
263
- live = { directories: /* @__PURE__ */ new Map() };
331
+ live = { directories: new WeakMapWithValues() };
264
332
  catalog;
265
333
  /** Localized composer-block copy; this plugin owns the string it raises. */
266
334
  blockReason;
@@ -275,7 +343,7 @@ window.__ModuleLoader__.load({
275
343
  this.catalog.load().catch(() => {});
276
344
  ctx.on("connection/reset", () => {
277
345
  this.catalog.resetGeneration();
278
- for (const directory of this.live.directories.values()) directory.resetConnected();
346
+ for (const directory of this.live.directories.values) directory.resetConnected();
279
347
  });
280
348
  ctx.remote.$on("llm/adapters-updated", () => {
281
349
  this.catalog.refresh();
@@ -295,15 +363,15 @@ window.__ModuleLoader__.load({
295
363
  */
296
364
  directoryFor(sessionId) {
297
365
  const { live } = this;
298
- const existing = live.directories.get(sessionId);
299
- if (existing !== void 0) return existing;
300
366
  const sessions = this.ctx.sessions;
301
367
  const actx = sessions.scope(sessionId);
302
368
  if (actx === void 0) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`);
303
369
  const binding = sessions.binding(sessionId);
304
370
  if (binding === void 0) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`);
371
+ const existing = live.directories.get(binding);
372
+ if (existing !== void 0) return existing;
305
373
  const directory = new ModelDirectory(this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === void 0, this.catalog, binding.session.projections.faceOf("modelSelection"));
306
- live.directories.set(sessionId, directory);
374
+ live.directories.set(binding, directory);
307
375
  const conversation = this.ctx.get("conversation");
308
376
  if (conversation !== void 0) {
309
377
  const publish = () => {
@@ -320,7 +388,7 @@ window.__ModuleLoader__.load({
320
388
  }
321
389
  actx.effect(() => () => {
322
390
  directory.dispose();
323
- live.directories.delete(sessionId);
391
+ live.directories.delete(binding);
324
392
  }, "ui-model-selection: session directory");
325
393
  return directory;
326
394
  }
@@ -387,11 +455,17 @@ window.__ModuleLoader__.load({
387
455
  * each drilling into its own list — the provider-grouped model list over
388
456
  * the shared directory, and the effort levels. The trigger (313:14108's
389
457
  * ToggleButton) shows both: model name + effort in the caption tone.
390
- * Data and submission ride the SAME per-session ModelDirectory as the
391
- * /model popup; exact-model reasoning metadata and the selected effort come
392
- * from the Host rather than a client-owned vocabulary. A rejected selection
393
- * announces through the shared transient Toast anchored to the composer
394
- * card; the in-menu strip with Retry remains the catalog-load surface.
458
+ * While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
459
+ * step taken while the trigger still holds focus enters at the near end), Tab
460
+ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and
461
+ * otherwise close back to the trigger. A drilled pane hands focus to the row
462
+ * of the value in use, and returning to the root pane hands it back to the
463
+ * cell that opened it. Data and submission ride the SAME per-session
464
+ * ModelDirectory as the /model popup; exact-model reasoning metadata and the
465
+ * selected effort come from the Host rather than a client-owned vocabulary. A
466
+ * rejected selection announces through the shared transient Toast anchored to
467
+ * the composer card; the in-menu strip with Retry remains the catalog-load
468
+ * surface.
395
469
  */
396
470
  /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */
397
471
  const MEASURE_STYLE = {
@@ -457,6 +531,18 @@ window.__ModuleLoader__.load({
457
531
  document.removeEventListener("mousedown", closeOutside);
458
532
  };
459
533
  }, [open]);
534
+ const paneFocus = (0, react.useRef)(null);
535
+ (0, react.useEffect)(() => {
536
+ const intent = paneFocus.current;
537
+ paneFocus.current = null;
538
+ if (!open || intent === null) return;
539
+ if (intent === "drill") {
540
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? itemRefs.current.find((item) => item !== null && !item.disabled) ?? triggerRef.current)?.focus();
541
+ return;
542
+ }
543
+ const cell = itemRefs.current[intent === "effort" ? 1 : 0];
544
+ (cell !== null && cell !== void 0 && !cell.disabled ? cell : triggerRef.current)?.focus();
545
+ }, [open, pane]);
460
546
  (0, react.useLayoutEffect)(() => {
461
547
  if (!open) {
462
548
  setMenuPos(null);
@@ -503,20 +589,48 @@ window.__ModuleLoader__.load({
503
589
  triggerRef.current?.focus();
504
590
  });
505
591
  };
592
+ const drill = (next) => {
593
+ paneFocus.current = "drill";
594
+ setPane(next);
595
+ };
596
+ /** Leave a drilled pane for the root one, handing the keyboard back to its cell. */
597
+ const back = (from) => {
598
+ paneFocus.current = from;
599
+ setPane("root");
600
+ };
506
601
  const moveFocus = (offset) => {
507
602
  const items = itemRefs.current.filter((item) => item !== null);
508
603
  if (items.length === 0) return;
509
604
  const active = items.findIndex((item) => item === document.activeElement);
510
- items[(Math.max(active, 0) + offset + items.length) % items.length]?.focus();
605
+ items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus();
511
606
  };
512
607
  const onRootKeyDown = (event) => {
513
608
  if (event.key === "Escape" && open) {
514
609
  event.preventDefault();
515
- if (pane !== "root") setPane("root");
610
+ if (pane !== "root") back(pane);
516
611
  else close(true);
517
612
  return;
518
613
  }
519
614
  if (!open) return;
615
+ if (event.key === "Tab") {
616
+ if (event.shiftKey) {
617
+ event.preventDefault();
618
+ if (pane !== "root") back(pane);
619
+ else close(true);
620
+ return;
621
+ }
622
+ const focused = document.activeElement;
623
+ const rows = itemRefs.current.filter((item) => item !== null);
624
+ if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
625
+ event.preventDefault();
626
+ focused.click();
627
+ return;
628
+ }
629
+ if (focused !== triggerRef.current) return;
630
+ event.preventDefault();
631
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? rows.find((item) => !item.disabled))?.focus();
632
+ return;
633
+ }
520
634
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
521
635
  event.preventDefault();
522
636
  moveFocus(event.key === "ArrowDown" ? 1 : -1);
@@ -526,19 +640,18 @@ window.__ModuleLoader__.load({
526
640
  if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return;
527
641
  close();
528
642
  };
529
- const settleSelection = (accepted) => {
530
- if (accepted) {
643
+ const settleSelection = (result) => {
644
+ if (result === void 0) return;
645
+ if (result.ok) {
531
646
  if (rootRef.current !== null) close(true);
532
647
  return;
533
648
  }
534
- const message = directory.getSnapshot().error;
535
- if (message !== null) {
536
- toastSeq.current += 1;
537
- setToast({
538
- seq: toastSeq.current,
539
- text: t("error.action", { message })
540
- });
541
- }
649
+ const { error } = result;
650
+ toastSeq.current += 1;
651
+ setToast({
652
+ seq: toastSeq.current,
653
+ text: error.code === "session/writer-held" ? t("error.sessionInUse") : t("error.action", { message: `${error.code}: ${error.message}` })
654
+ });
542
655
  };
543
656
  const choose = (selection) => {
544
657
  if (state.current?.provider === selection.provider && state.current.model === selection.model) {
@@ -628,7 +741,7 @@ window.__ModuleLoader__.load({
628
741
  role: "menuitem",
629
742
  className: ModelSelect_module_css_default.cell,
630
743
  onClick: () => {
631
- setPane("model");
744
+ drill("model");
632
745
  },
633
746
  children: [
634
747
  (0, react_jsx_runtime.jsx)("span", {
@@ -647,7 +760,7 @@ window.__ModuleLoader__.load({
647
760
  role: "menuitem",
648
761
  className: ModelSelect_module_css_default.cell,
649
762
  onClick: () => {
650
- setPane("effort");
763
+ drill("effort");
651
764
  },
652
765
  children: [
653
766
  (0, react_jsx_runtime.jsx)("span", {
@@ -793,6 +906,7 @@ window.__ModuleLoader__.load({
793
906
  */
794
907
  /** Simplified Chinese dictionary (the key-set source of truth). */
795
908
  const zh = {
909
+ "command.label": "模型",
796
910
  "command.description": "选择本会话使用的模型",
797
911
  "option.loadError": "目录加载失败:{message}",
798
912
  "option.deepseekV4Flash.description": "快速、高效且经济;适合目标明确、常规或并行任务。",
@@ -808,6 +922,7 @@ window.__ModuleLoader__.load({
808
922
  "effort.providerDefault": "Default",
809
923
  "status.loading": "正在刷新模型列表…",
810
924
  "error.action": "模型操作失败:{message}",
925
+ "error.sessionInUse": "当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。",
811
926
  "action.reload": "重新加载",
812
927
  "warning.groupLoad": "{name} 加载失败:{message}",
813
928
  "empty.models": "没有可用的模型。",
@@ -816,6 +931,7 @@ window.__ModuleLoader__.load({
816
931
  };
817
932
  /** English dictionary, checked complete against the zh key set. */
818
933
  const en = {
934
+ "command.label": "Model",
819
935
  "command.description": "Select the model for this conversation",
820
936
  "option.loadError": "Catalog failed to load: {message}",
821
937
  "option.deepseekV4Flash.description": "Fast, efficient, and economical; suited to focused, routine, or parallel tasks.",
@@ -831,6 +947,7 @@ window.__ModuleLoader__.load({
831
947
  "effort.providerDefault": "Default",
832
948
  "status.loading": "Refreshing model list…",
833
949
  "error.action": "Model operation failed: {message}",
950
+ "error.sessionInUse": "This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.",
834
951
  "action.reload": "Reload",
835
952
  "warning.groupLoad": "{name} failed to load: {message}",
836
953
  "empty.models": "No models available.",
@@ -918,7 +1035,9 @@ window.__ModuleLoader__.load({
918
1035
  const sessions = scope.sessions;
919
1036
  scope.effect(() => command.register({
920
1037
  name: "model",
1038
+ label: () => t("command.label"),
921
1039
  description: () => t("command.description"),
1040
+ icon: _deepseek_ai_dsh_client_ui_primitives.IconDataOutline16,
922
1041
  available: (session) => sessions.subagentAddress(session.sessionId) === void 0,
923
1042
  ui: {
924
1043
  kind: "popupSelect",
@@ -931,7 +1050,11 @@ window.__ModuleLoader__.load({
931
1050
  const directory = models.directoryFor(session.sessionId);
932
1051
  const selection = selectionOf(directory.store.getSnapshot(), option.id);
933
1052
  if (selection === void 0) throw new Error("this provider's catalog failed to load — pick a model from a loaded group");
934
- await directory.select(selection);
1053
+ const result = await directory.select(selection);
1054
+ if (!result.ok) {
1055
+ if (result.error.code === "session/writer-held") throw new Error(t("error.sessionInUse"));
1056
+ throw result.error;
1057
+ }
935
1058
  }
936
1059
  }
937
1060
  }), "ui-model-selection: /model contribution");
@@ -951,7 +1074,7 @@ window.__ModuleLoader__.load({
951
1074
  load: () => {
952
1075
  if (available) directory.load().catch(() => {});
953
1076
  },
954
- select: (selection) => available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false)
1077
+ select: (selection) => available ? directory.select(selection) : Promise.resolve(void 0)
955
1078
  };
956
1079
  }
957
1080
  }, ModelSelect));
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { ModelCatalogFailure, ModelProviderGroup, ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types';
8
8
  import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
9
- import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol';
9
+ import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol';
10
10
  import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store';
11
11
  import type { ModelCatalogDirectory } from './catalog.ts';
12
12
  /** Directory snapshot both entries render from. */
@@ -61,10 +61,11 @@ export declare class ModelDirectory {
61
61
  /**
62
62
  * Select the complete provider/model/reasoning selection. The durable
63
63
  * projection frame updates the shared current; failures surface on the store
64
- * and throw so each entry's own retry surface engages.
64
+ * and return with the operation so each entry can present its own failure.
65
65
  * @param selection - provider, provider-owned model id, and optional adapter-owned effort.
66
- */
67
- select(selection: ModelSelection): Promise<void>;
66
+ * @returns the selection outcome, including the original Remote failure.
67
+ */
68
+ select(selection: ModelSelection): Promise<RemoteResult<void>>;
68
69
  /**
69
70
  * Invalidate an in-flight selection response from the previous Host generation.
70
71
  */
@@ -9,6 +9,7 @@
9
9
  */
10
10
  /** Simplified Chinese dictionary (the key-set source of truth). */
11
11
  export declare const zh: {
12
+ 'command.label': string;
12
13
  'command.description': string;
13
14
  'option.loadError': string;
14
15
  'option.deepseekV4Flash.description': string;
@@ -24,6 +25,7 @@ export declare const zh: {
24
25
  'effort.providerDefault': string;
25
26
  'status.loading': string;
26
27
  'error.action': string;
28
+ 'error.sessionInUse': string;
27
29
  'action.reload': string;
28
30
  'warning.groupLoad': string;
29
31
  'empty.models': string;
@@ -34,6 +36,7 @@ export declare const zh: {
34
36
  export type ModelKey = keyof typeof zh;
35
37
  /** English dictionary, checked complete against the zh key set. */
36
38
  export declare const en: {
39
+ 'command.label': string;
37
40
  'command.description': string;
38
41
  'option.loadError': string;
39
42
  'option.deepseekV4Flash.description': string;
@@ -49,6 +52,7 @@ export declare const en: {
49
52
  'effort.providerDefault': string;
50
53
  'status.loading': string;
51
54
  'error.action': string;
55
+ 'error.sessionInUse': string;
52
56
  'action.reload': string;
53
57
  'warning.groupLoad': string;
54
58
  'empty.models': string;
@@ -4,6 +4,7 @@
4
4
  * entry; this package only contributes the single occupant, so no SlotMap
5
5
  * merge lives here.
6
6
  */
7
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
7
8
  import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client';
8
9
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
9
10
  import type { ModelDirectoryState } from './directory.ts';
@@ -18,8 +19,8 @@ export interface ModelSelectInjected {
18
19
  /**
19
20
  * Select a complete provider/model/reasoning selection.
20
21
  * @param selection - model selection and optional adapter-owned effort.
21
- * @returns whether the host accepted the selection.
22
+ * @returns the Host outcome, or undefined when this Session cannot select a model.
22
23
  */
23
- select: (selection: ModelSelection) => Promise<boolean>;
24
+ select: (selection: ModelSelection) => Promise<RemoteResult<void> | undefined>;
24
25
  }
25
26
  //# sourceMappingURL=slots.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-model-selection",
3
3
  "description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
4
- "version": "0.1.5-rc.2",
4
+ "version": "0.1.6-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -46,21 +46,22 @@
46
46
  "react-dom": "^18.2.0",
47
47
  "@types/react-dom": "~18.3.0",
48
48
  "clsx": "^2.1.1",
49
- "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.2",
50
- "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
51
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
52
- "@deepseek-ai/dsh-client-test-runtime": "^0.1.5-rc.2",
53
- "@deepseek-ai/dsh-client-ui-commands": "^0.1.5-rc.2",
54
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
55
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.5-rc.2",
56
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
57
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
49
+ "@deepseek-ai/dsh-api-remotes": "^0.1.6-alpha.2",
50
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.6-alpha.2",
51
+ "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.2",
52
+ "@deepseek-ai/dsh-client-test-runtime": "^0.1.6-alpha.2",
53
+ "@deepseek-ai/dsh-client-ui-commands": "^0.1.6-alpha.2",
54
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.6-alpha.2",
55
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.6-alpha.2",
56
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.2",
57
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.2",
58
58
  "@deepseek-ai/cordis": "^4.0.2",
59
- "@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.2",
60
- "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
61
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
62
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.2",
63
- "@deepseek-ai/dsh-client-ui-session": "^0.1.5-rc.2"
59
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.6-alpha.2",
60
+ "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.2",
61
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.2",
62
+ "@deepseek-ai/dsh-util-values": "^0.1.6-alpha.2",
63
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.6-alpha.2",
64
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.6-alpha.2"
64
65
  },
65
66
  "files": [
66
67
  "lib/index.js",