@deepseek-ai/dsh-client-ui-model-selection 0.1.6-alpha.1 → 0.1.7-alpha.1

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: f3c373619fec0482876d8ac540f06e188b5b34d9
6
- README.zh.md: b8ee9cd73bb3fd5cdf0d0ecd8559b271c5abc933
5
+ README.md: 37253cf04242275f22d646da0e65cfd44f4fefbf
6
+ README.zh.md: 4e339e6a6c6db8c3125e6ccc19c0fc3af9947f88
package/README.md CHANGED
@@ -25,16 +25,24 @@ 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
 
32
32
  Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
33
33
 
34
+ The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu.
35
+
34
36
  ### Unroutable sessions
35
37
 
36
38
  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
39
 
40
+ Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block.
41
+
42
+ ### Selection failures
43
+
44
+ When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
45
+
38
46
  -----
39
47
 
40
48
  <a id="understand-the-implementation"></a>
package/README.zh.md CHANGED
@@ -25,16 +25,24 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
25
25
  <a id="use-this-package"></a>
26
26
  ## 使用本包
27
27
 
28
- 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
28
+ 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
29
29
 
30
30
  ### 模型与推理强度
31
31
 
32
32
  模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
33
33
 
34
+ 展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。
35
+
34
36
  ### 不可路由的会话
35
37
 
36
38
  当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
37
39
 
40
+ 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。
41
+
42
+ ### 选择失败
43
+
44
+ 当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
45
+
38
46
  -----
39
47
 
40
48
  <a id="understand-the-implementation"></a>
package/lib/client.js CHANGED
@@ -10,6 +10,69 @@ window.__ModuleLoader__.load({
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
11
  let react = require("react");
12
12
  let react_dom = require("react-dom");
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,18 +363,19 @@ 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 = () => {
378
+ if (sessions.binding(sessionId) !== binding) return;
310
379
  conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false ? { reason: this.blockReason() } : void 0);
311
380
  };
312
381
  publish();
@@ -314,13 +383,15 @@ window.__ModuleLoader__.load({
314
383
  const stop = directory.store.subscribe(publish);
315
384
  return () => {
316
385
  stop();
386
+ const current = sessions.binding(sessionId);
387
+ if (current !== void 0 && current !== binding && live.directories.get(current) !== void 0) return;
317
388
  conversation.blocks.set(sessionId, void 0);
318
389
  };
319
390
  }, "ui-model-selection: composer block");
320
391
  }
321
392
  actx.effect(() => () => {
322
393
  directory.dispose();
323
- live.directories.delete(sessionId);
394
+ live.directories.delete(binding);
324
395
  }, "ui-model-selection: session directory");
325
396
  return directory;
326
397
  }
@@ -342,7 +413,7 @@ window.__ModuleLoader__.load({
342
413
  }
343
414
  //#endregion
344
415
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-model-selection/src/client/ModelSelect.module.css.mjs
345
- const css = "._7KE1Ra_root{min-width:0;position:relative}._7KE1Ra_trigger{min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:24px;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:500;line-height:20px;display:flex}._7KE1Ra_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}._7KE1Ra_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}._7KE1Ra_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-caption);flex-shrink:1000;overflow:hidden}._7KE1Ra_triggerIcon{flex:none;display:none}@container (width<=360px){._7KE1Ra_triggerIcon{display:block}._7KE1Ra_triggerLabel,._7KE1Ra_triggerEffort{display:none}}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:1100;background:var(--dsw-specific-menu);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;border-radius:20px;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}._7KE1Ra_status,._7KE1Ra_empty{color:var(--dsw-alias-label-tertiary);padding:10px;font-size:13px;line-height:20px}._7KE1Ra_error,._7KE1Ra_warning{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:4px;padding:7px 8px;font-size:12px;line-height:18px;display:flex}._7KE1Ra_warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}._7KE1Ra_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600}._7KE1Ra_groups{min-height:0;overflow-y:auto}._7KE1Ra_group+._7KE1Ra_group{margin-top:4px}._7KE1Ra_groupTitle{z-index:1;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-tertiary);padding:5px 8px 3px;font-size:12px;font-weight:500;line-height:18px;position:sticky;top:0}._7KE1Ra_option{box-sizing:border-box;width:auto;min-width:100%;min-height:38px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;border-radius:10px;outline:none;align-items:center;gap:8px;padding:6px 8px;display:flex}._7KE1Ra_option:hover:not(:disabled),._7KE1Ra_option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_selected{background:0 0}._7KE1Ra_option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}._7KE1Ra_modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:500;line-height:20px;overflow:hidden}._7KE1Ra_check{color:var(--dsw-alias-label-primary);flex:0 0 18px;place-items:center;display:grid}._7KE1Ra_cell{box-sizing:border-box;width:auto;min-width:100%;height:40px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;border-radius:10px;align-items:center;gap:8px;padding:0 10px;font-size:14px;line-height:22px;display:flex}._7KE1Ra_cell:hover{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_cellLabel{white-space:nowrap;flex:none}._7KE1Ra_cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}._7KE1Ra_cellChevron{color:var(--dsw-alias-label-tertiary);flex:none}";
416
+ const css = "._7KE1Ra_root{min-width:0;position:relative}._7KE1Ra_trigger{min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:24px;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:500;line-height:20px;display:flex}._7KE1Ra_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}._7KE1Ra_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}._7KE1Ra_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-caption);flex-shrink:1000;overflow:hidden}._7KE1Ra_triggerIcon{display:var(--dsh-composer-model-icon-display,none);flex:none}._7KE1Ra_triggerLabel,._7KE1Ra_triggerEffort{display:var(--dsh-composer-model-text-display,block)}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:1100;background:var(--dsw-specific-menu);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;border-radius:16px;flex-direction:column;padding:3px;display:flex;position:fixed;overflow:hidden}._7KE1Ra_status,._7KE1Ra_empty{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px}._7KE1Ra_error,._7KE1Ra_warning{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-radius:7px;justify-content:space-between;align-items:flex-start;gap:6px;margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px;display:flex}._7KE1Ra_warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}._7KE1Ra_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600}._7KE1Ra_groups{min-height:0;overflow-y:auto}._7KE1Ra_group+._7KE1Ra_group{margin-top:3px}._7KE1Ra_groupTitle{z-index:1;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-tertiary);padding:4px 7px 2px;font-size:11px;font-weight:500;line-height:16px;position:sticky;top:0}._7KE1Ra_option{box-sizing:border-box;width:auto;min-width:100%;min-height:34px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;border-radius:8px;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex}._7KE1Ra_option:hover:not(:disabled),._7KE1Ra_option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_selected{background:0 0}._7KE1Ra_option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}._7KE1Ra_modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px;overflow:hidden}._7KE1Ra_check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid}._7KE1Ra_check svg{width:14px;height:14px}._7KE1Ra_cell{box-sizing:border-box;width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;border-radius:8px;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex}._7KE1Ra_cell:hover{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_cellLabel{white-space:nowrap;flex:none}._7KE1Ra_cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}._7KE1Ra_cellChevron{width:12px;height:12px;color:var(--dsw-alias-label-tertiary);flex:none}";
346
417
  const tagId = "@deepseek-ai/dsh-client-ui-model-selection/ModelSelect.module.css";
347
418
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
348
419
  const tag = document.createElement("style");
@@ -387,11 +458,17 @@ window.__ModuleLoader__.load({
387
458
  * each drilling into its own list — the provider-grouped model list over
388
459
  * the shared directory, and the effort levels. The trigger (313:14108's
389
460
  * 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.
461
+ * While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
462
+ * step taken while the trigger still holds focus enters at the near end), Tab
463
+ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and
464
+ * otherwise close back to the trigger. A drilled pane hands focus to the row
465
+ * of the value in use, and returning to the root pane hands it back to the
466
+ * cell that opened it. Data and submission ride the SAME per-session
467
+ * ModelDirectory as the /model popup; exact-model reasoning metadata and the
468
+ * selected effort come from the Host rather than a client-owned vocabulary. A
469
+ * rejected selection announces through the shared transient Toast anchored to
470
+ * the composer card; the in-menu strip with Retry remains the catalog-load
471
+ * surface.
395
472
  */
396
473
  /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */
397
474
  const MEASURE_STYLE = {
@@ -457,6 +534,18 @@ window.__ModuleLoader__.load({
457
534
  document.removeEventListener("mousedown", closeOutside);
458
535
  };
459
536
  }, [open]);
537
+ const paneFocus = (0, react.useRef)(null);
538
+ (0, react.useEffect)(() => {
539
+ const intent = paneFocus.current;
540
+ paneFocus.current = null;
541
+ if (!open || intent === null) return;
542
+ if (intent === "drill") {
543
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? itemRefs.current.find((item) => item !== null && !item.disabled) ?? triggerRef.current)?.focus();
544
+ return;
545
+ }
546
+ const cell = itemRefs.current[intent === "effort" ? 1 : 0];
547
+ (cell !== null && cell !== void 0 && !cell.disabled ? cell : triggerRef.current)?.focus();
548
+ }, [open, pane]);
460
549
  (0, react.useLayoutEffect)(() => {
461
550
  if (!open) {
462
551
  setMenuPos(null);
@@ -503,20 +592,48 @@ window.__ModuleLoader__.load({
503
592
  triggerRef.current?.focus();
504
593
  });
505
594
  };
595
+ const drill = (next) => {
596
+ paneFocus.current = "drill";
597
+ setPane(next);
598
+ };
599
+ /** Leave a drilled pane for the root one, handing the keyboard back to its cell. */
600
+ const back = (from) => {
601
+ paneFocus.current = from;
602
+ setPane("root");
603
+ };
506
604
  const moveFocus = (offset) => {
507
605
  const items = itemRefs.current.filter((item) => item !== null);
508
606
  if (items.length === 0) return;
509
607
  const active = items.findIndex((item) => item === document.activeElement);
510
- items[(Math.max(active, 0) + offset + items.length) % items.length]?.focus();
608
+ items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus();
511
609
  };
512
610
  const onRootKeyDown = (event) => {
513
611
  if (event.key === "Escape" && open) {
514
612
  event.preventDefault();
515
- if (pane !== "root") setPane("root");
613
+ if (pane !== "root") back(pane);
516
614
  else close(true);
517
615
  return;
518
616
  }
519
617
  if (!open) return;
618
+ if (event.key === "Tab") {
619
+ if (event.shiftKey) {
620
+ event.preventDefault();
621
+ if (pane !== "root") back(pane);
622
+ else close(true);
623
+ return;
624
+ }
625
+ const focused = document.activeElement;
626
+ const rows = itemRefs.current.filter((item) => item !== null);
627
+ if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
628
+ event.preventDefault();
629
+ focused.click();
630
+ return;
631
+ }
632
+ if (focused !== triggerRef.current) return;
633
+ event.preventDefault();
634
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? rows.find((item) => !item.disabled))?.focus();
635
+ return;
636
+ }
520
637
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
521
638
  event.preventDefault();
522
639
  moveFocus(event.key === "ArrowDown" ? 1 : -1);
@@ -526,19 +643,18 @@ window.__ModuleLoader__.load({
526
643
  if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return;
527
644
  close();
528
645
  };
529
- const settleSelection = (accepted) => {
530
- if (accepted) {
646
+ const settleSelection = (result) => {
647
+ if (result === void 0) return;
648
+ if (result.ok) {
531
649
  if (rootRef.current !== null) close(true);
532
650
  return;
533
651
  }
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
- }
652
+ const { error } = result;
653
+ toastSeq.current += 1;
654
+ setToast({
655
+ seq: toastSeq.current,
656
+ text: error.code === "session/writer-held" ? t("error.sessionInUse") : t("error.action", { message: `${error.code}: ${error.message}` })
657
+ });
542
658
  };
543
659
  const choose = (selection) => {
544
660
  if (state.current?.provider === selection.provider && state.current.model === selection.model) {
@@ -598,7 +714,7 @@ window.__ModuleLoader__.load({
598
714
  else show();
599
715
  },
600
716
  children: [
601
- (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutline16, {
717
+ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular, {
602
718
  className: ModelSelect_module_css_default.triggerIcon,
603
719
  size: 16
604
720
  }),
@@ -610,7 +726,7 @@ window.__ModuleLoader__.load({
610
726
  className: ModelSelect_module_css_default.triggerEffort,
611
727
  children: effortLabel
612
728
  }),
613
- (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: clsx(ModelSelect_module_css_default.chevron, open && ModelSelect_module_css_default.chevronOpen) })
729
+ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: clsx(ModelSelect_module_css_default.chevron, open && ModelSelect_module_css_default.chevronOpen) })
614
730
  ]
615
731
  }),
616
732
  open && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)("div", {
@@ -628,7 +744,7 @@ window.__ModuleLoader__.load({
628
744
  role: "menuitem",
629
745
  className: ModelSelect_module_css_default.cell,
630
746
  onClick: () => {
631
- setPane("model");
747
+ drill("model");
632
748
  },
633
749
  children: [
634
750
  (0, react_jsx_runtime.jsx)("span", {
@@ -639,7 +755,7 @@ window.__ModuleLoader__.load({
639
755
  className: ModelSelect_module_css_default.cellValue,
640
756
  children: modelLabel
641
757
  }),
642
- (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutline14, { className: ModelSelect_module_css_default.cellChevron })
758
+ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: ModelSelect_module_css_default.cellChevron })
643
759
  ]
644
760
  }), reasoning !== void 0 && (0, react_jsx_runtime.jsxs)("button", {
645
761
  ref: itemRef(),
@@ -647,7 +763,7 @@ window.__ModuleLoader__.load({
647
763
  role: "menuitem",
648
764
  className: ModelSelect_module_css_default.cell,
649
765
  onClick: () => {
650
- setPane("effort");
766
+ drill("effort");
651
767
  },
652
768
  children: [
653
769
  (0, react_jsx_runtime.jsx)("span", {
@@ -658,7 +774,7 @@ window.__ModuleLoader__.load({
658
774
  className: ModelSelect_module_css_default.cellValue,
659
775
  children: effortLabel
660
776
  }),
661
- (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutline14, { className: ModelSelect_module_css_default.cellChevron })
777
+ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: ModelSelect_module_css_default.cellChevron })
662
778
  ]
663
779
  })] }),
664
780
  pane === "model" && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -723,7 +839,7 @@ window.__ModuleLoader__.load({
723
839
  })
724
840
  }), (0, react_jsx_runtime.jsx)("span", {
725
841
  className: ModelSelect_module_css_default.check,
726
- children: selected ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
842
+ children: selected ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
727
843
  })]
728
844
  }, model.id);
729
845
  })]
@@ -764,14 +880,14 @@ window.__ModuleLoader__.load({
764
880
  })
765
881
  }), (0, react_jsx_runtime.jsx)("span", {
766
882
  className: ModelSelect_module_css_default.check,
767
- children: effectiveEffort === level.effort ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
883
+ children: effectiveEffort === level.effort ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
768
884
  })]
769
885
  }, level.key))] })
770
886
  ]
771
887
  }), document.body),
772
888
  toast !== null && (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Toast, {
773
889
  text: toast.text,
774
- icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {}),
890
+ icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutlineRegular, {}),
775
891
  anchor: rootRef.current?.closest("[data-composer-card]") ?? null,
776
892
  onDone: () => {
777
893
  setToast(null);
@@ -809,6 +925,7 @@ window.__ModuleLoader__.load({
809
925
  "effort.providerDefault": "Default",
810
926
  "status.loading": "正在刷新模型列表…",
811
927
  "error.action": "模型操作失败:{message}",
928
+ "error.sessionInUse": "当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。",
812
929
  "action.reload": "重新加载",
813
930
  "warning.groupLoad": "{name} 加载失败:{message}",
814
931
  "empty.models": "没有可用的模型。",
@@ -833,6 +950,7 @@ window.__ModuleLoader__.load({
833
950
  "effort.providerDefault": "Default",
834
951
  "status.loading": "Refreshing model list…",
835
952
  "error.action": "Model operation failed: {message}",
953
+ "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.",
836
954
  "action.reload": "Reload",
837
955
  "warning.groupLoad": "{name} failed to load: {message}",
838
956
  "empty.models": "No models available.",
@@ -922,7 +1040,7 @@ window.__ModuleLoader__.load({
922
1040
  name: "model",
923
1041
  label: () => t("command.label"),
924
1042
  description: () => t("command.description"),
925
- icon: _deepseek_ai_dsh_client_ui_primitives.IconDataOutline16,
1043
+ icon: _deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular,
926
1044
  available: (session) => sessions.subagentAddress(session.sessionId) === void 0,
927
1045
  ui: {
928
1046
  kind: "popupSelect",
@@ -935,7 +1053,11 @@ window.__ModuleLoader__.load({
935
1053
  const directory = models.directoryFor(session.sessionId);
936
1054
  const selection = selectionOf(directory.store.getSnapshot(), option.id);
937
1055
  if (selection === void 0) throw new Error("this provider's catalog failed to load — pick a model from a loaded group");
938
- await directory.select(selection);
1056
+ const result = await directory.select(selection);
1057
+ if (!result.ok) {
1058
+ if (result.error.code === "session/writer-held") throw new Error(t("error.sessionInUse"));
1059
+ throw result.error;
1060
+ }
939
1061
  }
940
1062
  }
941
1063
  }), "ui-model-selection: /model contribution");
@@ -955,7 +1077,7 @@ window.__ModuleLoader__.load({
955
1077
  load: () => {
956
1078
  if (available) directory.load().catch(() => {});
957
1079
  },
958
- select: (selection) => available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false)
1080
+ select: (selection) => available ? directory.select(selection) : Promise.resolve(void 0)
959
1081
  };
960
1082
  }
961
1083
  }, 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
  */
@@ -25,6 +25,7 @@ export declare const zh: {
25
25
  'effort.providerDefault': string;
26
26
  'status.loading': string;
27
27
  'error.action': string;
28
+ 'error.sessionInUse': string;
28
29
  'action.reload': string;
29
30
  'warning.groupLoad': string;
30
31
  'empty.models': string;
@@ -51,6 +52,7 @@ export declare const en: {
51
52
  'effort.providerDefault': string;
52
53
  'status.loading': string;
53
54
  'error.action': string;
55
+ 'error.sessionInUse': string;
54
56
  'action.reload': string;
55
57
  'warning.groupLoad': string;
56
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.6-alpha.1",
4
+ "version": "0.1.7-alpha.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "peerDependencies": {
41
- "@deepseek-ai/cordis": "^4.0.2"
41
+ "@deepseek-ai/cordis": "^4.0.3"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "~18.3.1",
@@ -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.6-alpha.1",
50
- "@deepseek-ai/dsh-api-session-controller": "^0.1.6-alpha.1",
51
- "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.1",
52
- "@deepseek-ai/dsh-client-test-runtime": "^0.1.6-alpha.1",
53
- "@deepseek-ai/dsh-client-ui-commands": "^0.1.6-alpha.1",
54
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.6-alpha.1",
55
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.6-alpha.1",
56
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.6-alpha.1",
57
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.1",
58
- "@deepseek-ai/cordis": "^4.0.2",
59
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
60
- "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.1",
61
- "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
62
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.6-alpha.1",
63
- "@deepseek-ai/dsh-client-ui-session": "^0.1.6-alpha.1"
49
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.7-alpha.1",
50
+ "@deepseek-ai/dsh-client-test-runtime": "^0.1.7-alpha.1",
51
+ "@deepseek-ai/dsh-client-ui-commands": "^0.1.7-alpha.1",
52
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.7-alpha.1",
53
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.7-alpha.1",
54
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-alpha.1",
55
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-alpha.1",
56
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.7-alpha.1",
57
+ "@deepseek-ai/cordis": "^4.0.3",
58
+ "@deepseek-ai/dsh-client-store": "^0.1.7-alpha.1",
59
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-alpha.1",
60
+ "@deepseek-ai/dsh-util-values": "^0.1.7-alpha.1",
61
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.7-alpha.1",
62
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-alpha.1",
63
+ "@deepseek-ai/dsh-session": "^0.1.7-alpha.1",
64
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-alpha.1"
64
65
  },
65
66
  "files": [
66
67
  "lib/index.js",