@deepseek-ai/dsh-client-ui-model-selection 0.1.1-rc.2 → 0.1.2-alpha.3

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: 929d23ad4af1fe41e1cc7398ef432e73ca2a7a94
6
- README.zh.md: 23e786d9e490b0739498a3e83703b42ebc345cbd
5
+ README.md: 3883ebf860b9503e69df770e8d1dc8a8769fa9d0
6
+ README.zh.md: 943a21a6815896e6cee7996bc05b32e938244fe1
package/README.md CHANGED
@@ -1,22 +1,70 @@
1
+ ---
2
+ description: "Model selection for the Web GUI: the /model popup and the composer model seat over one per-session provider-grouped directory; for users and maintainers of model routing."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-ui-model-selection
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`). For ordinary sessions, 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. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort.
10
+ ## Summary
11
+
12
+ This package provides model selection in the Web GUI: the `/model` popup command and the composer's model seat, both over one per-session directory of provider-grouped models. Choosing a model submits the complete selection — provider, model, and reasoning effort — which the Host snapshots at the next prompt-assembly boundary, so the following request uses it while a running step keeps its assembled selection. The composer seat shows a two-level Model/Effort menu: models stay provider-grouped, and the selected exact model supplies its adapter-owned effort names and default. When the Host reports that no adapter serves the session's route, the composer input goes inert until a route becomes available.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
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`.
29
+
30
+ ### Model and effort
31
+
32
+ Models stay grouped by provider. The menu shows model and effort names only; catalog descriptions remain available to other consumers. The `/model` 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
+
34
+ ### Unroutable sessions
35
+
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
+
38
+ -----
6
39
 
7
- The Host-reported provider/model/reasoning `ModelSelection` is the single selection fact, but it is echoed only when the exact provider/model pair remains in the advertised groups; an absent catalog row leaves the routable selection intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. 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. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior selection and directory.
40
+ <a id="understand-the-implementation"></a>
41
+ ## Understand the implementation
8
42
 
9
- When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate.
43
+ <details>
44
+ <summary>Implementation internals — click to expand</summary>
10
45
 
11
- Directories are per-session, resolved lazily through `ctx.modelDirectories.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
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.
12
47
 
13
- Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. Provider topology, provider catalogs, and the default selection therefore converge without the Host or client runtime deriving a separate model-change alias.
48
+ </details>
14
49
 
15
- The `/client` exports are the plugin body (`apply`/`inject`), `ModelDirectoryResolver`, `ModelDirectory` with its state fields, and the seat's injected face type.
50
+ -----
16
51
 
52
+ <a id="further-exploration"></a>
53
+ ## Further Exploration
54
+
55
+ Read these pages when the model surface is not enough. They move from the browser surfaces to the command popup shell and the selection contract.
56
+
57
+ - [ui-commands](../ui-commands/README.md) — the popupSelect shell the `/model` contribution registers into.
58
+ - [ui-conversation](../ui-conversation/README.md) — declares the composer's `conversation.input.model` seat and the composer block.
59
+ - [dsh-agent-default-model](../../core/agent-default-model/README.md) — the default-model service for sessions that never choose.
60
+ - [Client package map](../README.md) — adjacent browser UI packages.
61
+
62
+ -----
63
+
64
+ <a id="model-experience"></a>
17
65
  ## Model Experience
18
66
 
19
- Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the complete `ModelSelection` that the Host snapshots at the next prompt-assembly boundary, so the following request uses the selected provider, model, and effort while a running step keeps its assembled selection; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
67
+ Indirectly, through the `session.selectModel` selection both entries submit: the Host snapshots the complete `ModelSelection` at the next prompt-assembly boundary and owns the model-visible effect, while a running step keeps its assembled selection.
20
68
 
21
69
  #### KV Cache effect
22
70
 
@@ -24,6 +72,21 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
24
72
 
25
73
  ## Known Limitations and Deferred Work
26
74
 
75
+ <a id="known-limitations-and-deferred-work"></a>
76
+
77
+
78
+ These limits define the current model surface. They are current package constraints, not a general model-router comparison or a task backlog.
79
+
27
80
  - **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract.
28
81
  - **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
29
82
  - **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.
83
+
84
+ <a id="dev-note"></a>
85
+ ### Dev Note
86
+
87
+ <details>
88
+ <summary>Working context for maintainers — click to expand</summary>
89
+
90
+ None.
91
+
92
+ </details>
package/README.zh.md CHANGED
@@ -1,29 +1,92 @@
1
+ ---
2
+ description: "Web GUI 的模型选择:/model 弹窗与 composer 模型位共用一份按提供方分组的会话级目录;供模型路由的用户与维护者阅读。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-client-ui-model-selection
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。
10
+ ## 概述
11
+
12
+ 本包提供 Web GUI 的模型选择:`/model` 弹窗命令与 composer 模型位,两者共用一份按提供方分组的会话级目录。选择模型会提交完整选择——提供方、模型与推理强度——宿主在下一次提示词组装边界对其快照,因此后续请求采用该选择,而运行中的步骤保留已组装选择。composer 位显示两级 Model/Effort 菜单:模型按提供方分组,所选具体模型提供其适配器持有的推理强度名称与默认值。当宿主报告没有适配器服务该会话的路由时,composer 输入停用,直到路由恢复可用。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。当确切提供方/模型对仍在已公布分组中时,两个表面都显示宿主报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
29
+
30
+ ### 模型与推理强度
31
+
32
+ 模型按提供方分组。菜单只显示模型与推理强度名称;目录中的说明仍可供其他消费方使用。`/model` 弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
33
+
34
+ ### 不可路由的会话
35
+
36
+ 当宿主报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入随本插件自己的文案停用;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
37
+
38
+ -----
6
39
 
7
- Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方、模型与推理(reasoning)强度;但只有当该提供方/模型对仍在已公布分组中时才会回显。目录行缺席时,可路由的选择保持不变,但触发器会提示 `Select model`;系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的选择。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的选择和目录。
40
+ <a id="understand-the-implementation"></a>
41
+ ## 理解实现
8
42
 
9
- 当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来可用的 composer;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。
43
+ <details>
44
+ <summary>实现细节——点击展开</summary>
10
45
 
11
- 目录按会话惰性解析(`ctx.modelDirectories.directoryFor(sessionId)`),随会话作用域一并 dispose(资源释放)。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行路径之外激活持久化 child 历史。
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 事件上重拉。
12
47
 
13
- 每一份常驻目录都会直接在转发的 owner 事件 `llm/adapters-updated` 与 `settings/document-updated` 上重拉。因此提供方拓扑、提供方目录与默认选择都能收敛,Host 与 client runtime 无需再派生一个单独的模型变更别名。
48
+ </details>
14
49
 
15
- `/client` 导出面为插件本体(`apply`/`inject`)、`ModelDirectoryResolver`、`ModelDirectory` 及其状态形状、slot 注入面类型。
50
+ -----
16
51
 
52
+ <a id="further-exploration"></a>
53
+ ## 进一步探索
54
+
55
+ 当模型面不够用时阅读以下页面。它们从浏览器表面进入命令弹窗外壳与选择约定。
56
+
57
+ - [ui-commands](../ui-commands/README.zh.md)——`/model` 贡献项注册进的 popupSelect 外壳。
58
+ - [ui-conversation](../ui-conversation/README.zh.md)——声明 composer 的 `conversation.input.model` 位与 composer 阻塞块。
59
+ - [dsh-agent-default-model](../../core/agent-default-model/README.zh.md)——为从未选择的会话提供默认模型的默认模型服务。
60
+ - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。
61
+
62
+ -----
63
+
64
+ <a id="model-experience"></a>
17
65
  ## 模型体验
18
66
 
19
- 间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交完整的 `ModelSelection`;Host 会在下一次提示词组装边界对其进行快照,因此后续请求采用所选提供方、模型与推理强度,而运行中的步骤保留已组装选择。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
67
+ 间接影响。两个入口都提交 `session.selectModel` 选择;宿主在下一次提示词组装边界对完整 `ModelSelection` 快照并拥有模型可见效果,而运行中的步骤保留已组装选择。
20
68
 
21
69
  #### KV Cache 影响
22
70
 
23
71
  切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
24
72
 
25
- ## 已知限制与暂缓事项
73
+ ## 已知限制与延期工作
74
+
75
+ <a id="known-limitations-and-deferred-work"></a>
76
+
77
+
78
+ 这些限制界定了当前模型表面。它们是当前包约束,不是通用模型路由器对比或任务积压。
26
79
 
27
80
  - **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
28
81
  - **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
29
82
  - **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
83
+
84
+ <a id="dev-note"></a>
85
+ ### 开发备注
86
+
87
+ <details>
88
+ <summary>维护者的工作上下文——点击展开</summary>
89
+
90
+ 无。
91
+
92
+ </details>
package/lib/client.js CHANGED
@@ -5,18 +5,99 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let _deepseek_ai_cordis = require("@deepseek-ai/cordis");
8
- let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
8
+ let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
10
  let react = require("react");
11
11
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
12
+ //#region lib/types/client/catalog.js
13
+ /** One Host-generation model catalog shared by every Session selector. */
14
+ /** Loads at most one model catalog for the current Host generation. */
15
+ var ModelCatalogDirectory = class {
16
+ ctx;
17
+ /** Current shared catalog value and load lifecycle. */
18
+ store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)({
19
+ value: null,
20
+ status: "idle",
21
+ error: null
22
+ });
23
+ generation = 0;
24
+ inflight;
25
+ /**
26
+ * @param ctx - the providing plugin's context, whose `remote.session`
27
+ * namespace carries the Host-generation catalog.
28
+ */
29
+ constructor(ctx) {
30
+ this.ctx = ctx;
31
+ }
32
+ /**
33
+ * Return the current generation's catalog, sharing its one in-flight load.
34
+ * @returns the loaded global catalog.
35
+ */
36
+ load() {
37
+ const state = this.store.getSnapshot();
38
+ if (state.status === "ready" && state.value !== null) return Promise.resolve(state.value);
39
+ if (this.inflight !== void 0) return this.inflight;
40
+ const generation = this.generation;
41
+ this.store.update((draft) => {
42
+ draft.status = "loading";
43
+ draft.error = null;
44
+ });
45
+ const operation = this.ctx.remote.session.modelCatalog().then((response) => {
46
+ if (!response.ok) throw new Error(`${response.error.code}: ${response.error.message}`);
47
+ if (generation === this.generation) this.store.set({
48
+ value: response.value,
49
+ status: "ready",
50
+ error: null
51
+ });
52
+ return response.value;
53
+ }).catch((error) => {
54
+ if (generation === this.generation) this.store.update((draft) => {
55
+ draft.status = "error";
56
+ draft.error = error instanceof Error ? error.message : String(error);
57
+ });
58
+ throw error;
59
+ }).finally(() => {
60
+ if (generation === this.generation && this.inflight === operation) this.inflight = void 0;
61
+ });
62
+ this.inflight = operation;
63
+ return operation;
64
+ }
65
+ /**
66
+ * Invalidate the loaded catalog; the next explicit menu read reloads it.
67
+ * @param clear - whether values from the previous Host generation must be hidden.
68
+ */
69
+ invalidate(clear = false) {
70
+ this.generation += 1;
71
+ this.inflight = void 0;
72
+ const value = clear ? null : this.store.getSnapshot().value;
73
+ this.store.set({
74
+ value,
75
+ status: "idle",
76
+ error: null
77
+ });
78
+ }
79
+ /** Invalidate and reload the catalog after a Host-side model input changes. */
80
+ refresh() {
81
+ this.invalidate();
82
+ this.load().catch(() => {});
83
+ }
84
+ /** Clear Host-specific values and load the replacement Host generation. */
85
+ resetGeneration() {
86
+ this.invalidate(true);
87
+ this.load().catch(() => {});
88
+ }
89
+ };
90
+ //#endregion
12
91
  //#region lib/types/client/directory.js
13
92
  /** One session's shared directory controller; disposed with the session scope. */
14
93
  var ModelDirectory = class {
15
94
  sessions;
16
95
  sessionId;
17
96
  available;
97
+ catalog;
98
+ projected;
18
99
  /** The shared snapshot both entries render from (uSES-safe store). */
19
- store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)({
100
+ store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)({
20
101
  current: null,
21
102
  routable: null,
22
103
  groups: [],
@@ -24,58 +105,47 @@ window.__ModuleLoader__.load({
24
105
  status: "idle",
25
106
  error: null
26
107
  });
27
- /** Latest operation wins; an older response never overwrites a newer one. */
108
+ /** Latest selection operation wins; an older response never overwrites a newer one. */
28
109
  generation = 0;
29
110
  disposed = false;
111
+ resolved = false;
112
+ unsubscribeCatalog;
113
+ unsubscribeSelection;
30
114
  /**
31
115
  * @param sessions - the session wire face (captured from the plugin's root connection).
32
116
  * @param sessionId - the owning session.
33
117
  * @param available - whether this session may use Agent-bound model RPCs.
118
+ * @param catalog - Host-generation catalog shared by every Session.
119
+ * @param projected - durable model selection projected from Session history.
34
120
  */
35
- constructor(sessions, sessionId, available) {
121
+ constructor(sessions, sessionId, available, catalog, projected) {
36
122
  this.sessions = sessions;
37
123
  this.sessionId = sessionId;
38
124
  this.available = available;
125
+ this.catalog = catalog;
126
+ this.projected = projected;
127
+ this.unsubscribeCatalog = catalog.store.subscribe(() => {
128
+ this.syncInputs();
129
+ });
130
+ this.unsubscribeSelection = projected.subscribe(() => {
131
+ this.syncInputs();
132
+ });
133
+ this.syncInputs();
39
134
  }
40
135
  /**
41
- * Refresh the advisory directory (both entries call this on open).
42
- * Failure preserves the last good groups and current selection.
136
+ * Ensure the Host generation's shared advisory catalog is loaded.
43
137
  * @returns the fresh directory value.
44
138
  */
45
139
  async load() {
46
140
  this.assertAvailable();
47
- const generation = ++this.generation;
48
- this.store.update((s) => {
49
- s.status = "loading";
50
- s.error = null;
51
- });
52
- const { result } = await this.sessions.models({ sessionId: this.sessionId });
53
- if (this.disposed || generation !== this.generation) {
54
- if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
55
- return result.value;
56
- }
57
- if (!result.ok) {
58
- this.store.update((s) => {
59
- s.status = "error";
60
- s.error = `${result.error.code}: ${result.error.message}`;
61
- });
62
- throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`);
63
- }
64
- const { current, routable, groups, failures } = result.value;
65
- this.store.update((s) => {
66
- s.current = current;
67
- s.routable = routable;
68
- s.groups = groups;
69
- s.failures = failures;
70
- s.status = "ready";
71
- s.error = null;
72
- });
73
- return result.value;
141
+ await this.catalog.load();
142
+ this.syncInputs();
143
+ return this.store.getSnapshot();
74
144
  }
75
145
  /**
76
- * Select the complete provider/model/reasoning selection (both entries submit through here). Success
77
- * updates the shared current; failure surfaces on the store and throws so
78
- * each entry's own retry surface engages.
146
+ * Select the complete provider/model/reasoning selection. The durable
147
+ * projection frame updates the shared current; failures surface on the store
148
+ * and throw so each entry's own retry surface engages.
79
149
  * @param selection - provider, provider-owned model id, and optional adapter-owned effort.
80
150
  */
81
151
  async select(selection) {
@@ -85,7 +155,7 @@ window.__ModuleLoader__.load({
85
155
  s.status = "selecting";
86
156
  s.error = null;
87
157
  });
88
- const { result } = await this.sessions.selectModel({
158
+ const result = await this.sessions.selectModel({
89
159
  sessionId: this.sessionId,
90
160
  provider: selection.provider,
91
161
  model: selection.model,
@@ -103,39 +173,69 @@ window.__ModuleLoader__.load({
103
173
  throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`);
104
174
  }
105
175
  this.store.update((s) => {
106
- s.current = result.value.selected;
107
- s.routable = true;
108
176
  s.status = "ready";
109
177
  s.error = null;
110
178
  });
179
+ this.syncInputs();
111
180
  }
112
181
  /**
113
- * Drop the previous Host generation's projection and repull it. Clearing
114
- * first prevents an unconsumed process-local selection from being displayed
115
- * while the restarted Host has restored the last logged model selection.
182
+ * Invalidate an in-flight selection response from the previous Host generation.
116
183
  */
117
184
  resetConnected() {
118
185
  if (this.disposed) return;
119
186
  ++this.generation;
120
- this.store.update((s) => {
121
- s.current = null;
122
- s.routable = null;
123
- s.groups = [];
124
- s.failures = [];
125
- s.status = "idle";
126
- s.error = null;
187
+ this.store.update((state) => {
188
+ if (state.status === "selecting") state.status = "idle";
189
+ state.error = null;
127
190
  });
128
- if (!this.available()) return;
129
- this.load().catch(() => {});
191
+ this.syncInputs();
130
192
  }
131
193
  /** Scope teardown: late settlements lose write access to the store. */
132
194
  dispose() {
133
195
  this.disposed = true;
196
+ this.unsubscribeSelection();
197
+ this.unsubscribeCatalog();
134
198
  }
135
199
  assertAvailable() {
136
200
  if (!this.available()) throw new Error("model selection is unavailable for addressed subagent sessions");
137
201
  }
202
+ syncInputs() {
203
+ if (this.disposed) return;
204
+ const catalog = this.catalog.store.getSnapshot();
205
+ const projected = modelSelectionProjection(this.projected.getSnapshot());
206
+ if (catalog.status !== "ready" || catalog.value === null || projected === void 0) {
207
+ if (this.resolved) {
208
+ if (catalog.status === "error") this.store.update((state) => {
209
+ state.status = "error";
210
+ state.error = catalog.error;
211
+ });
212
+ return;
213
+ }
214
+ this.store.set({
215
+ current: null,
216
+ routable: null,
217
+ groups: [],
218
+ failures: [],
219
+ status: catalog.status === "error" ? "error" : "loading",
220
+ error: catalog.error
221
+ });
222
+ return;
223
+ }
224
+ const current = projected.next ?? catalog.value.default;
225
+ this.resolved = true;
226
+ this.store.set({
227
+ current,
228
+ routable: catalog.value.routableProviders.includes(current.provider),
229
+ groups: catalog.value.groups,
230
+ failures: catalog.value.failures,
231
+ status: this.store.getSnapshot().status === "selecting" ? "selecting" : "ready",
232
+ error: null
233
+ });
234
+ }
138
235
  };
236
+ function modelSelectionProjection(value) {
237
+ return value === void 0 ? void 0 : value;
238
+ }
139
239
  //#endregion
140
240
  //#region lib/types/client/service.js
141
241
  /**
@@ -155,11 +255,12 @@ window.__ModuleLoader__.load({
155
255
  /** The `ctx.modelDirectories` session model-selection service. */
156
256
  var ModelDirectoryResolver = class extends _deepseek_ai_cordis.Service {
157
257
  static inject = [
158
- "connection",
159
258
  "sessions",
160
- "remote"
259
+ "remote",
260
+ "remote.session"
161
261
  ];
162
262
  live = { directories: /* @__PURE__ */ new Map() };
263
+ catalog;
163
264
  /** Localized composer-block copy; this plugin owns the string it raises. */
164
265
  blockReason;
165
266
  /**
@@ -169,14 +270,21 @@ window.__ModuleLoader__.load({
169
270
  constructor(ctx, config) {
170
271
  super(ctx, "modelDirectories");
171
272
  this.blockReason = config.blockReason;
273
+ this.catalog = new ModelCatalogDirectory(ctx);
274
+ this.catalog.load().catch(() => {});
172
275
  ctx.on("connection/reset", () => {
276
+ this.catalog.resetGeneration();
173
277
  for (const directory of this.live.directories.values()) directory.resetConnected();
174
278
  });
175
- const refresh = () => {
176
- for (const directory of this.live.directories.values()) directory.load().catch(() => void 0);
177
- };
178
- ctx.remote.$on("llm/adapters-updated", refresh);
179
- ctx.remote.$on("settings/document-updated", refresh);
279
+ ctx.remote.$on("llm/adapters-updated", () => {
280
+ this.catalog.refresh();
281
+ });
282
+ ctx.remote.$on("settings/document-updated", () => {
283
+ this.catalog.refresh();
284
+ });
285
+ ctx.remote.$on("credentials/reference-updated", () => {
286
+ this.catalog.refresh();
287
+ });
180
288
  }
181
289
  /**
182
290
  * Resolve the per-session shared directory (lazy; the scope disposer
@@ -188,10 +296,12 @@ window.__ModuleLoader__.load({
188
296
  const { live } = this;
189
297
  const existing = live.directories.get(sessionId);
190
298
  if (existing !== void 0) return existing;
191
- const sessions = this.ctx.get("sessions");
299
+ const sessions = this.ctx.sessions;
192
300
  const actx = sessions.scope(sessionId);
193
301
  if (actx === void 0) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`);
194
- const directory = new ModelDirectory(this.ctx.get("connection").api.sessions, sessionId, () => sessions.subagentAddress(sessionId) === void 0);
302
+ const binding = sessions.binding(sessionId);
303
+ if (binding === void 0) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`);
304
+ const directory = new ModelDirectory(this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === void 0, this.catalog, binding.session.projections.faceOf("modelSelection"));
195
305
  live.directories.set(sessionId, directory);
196
306
  const conversation = this.ctx.get("conversation");
197
307
  if (conversation !== void 0) {
@@ -231,7 +341,7 @@ window.__ModuleLoader__.load({
231
341
  }
232
342
  //#endregion
233
343
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-model-selection/src/client/ModelSelect.module.css.mjs
234
- 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{color:var(--dsw-alias-label-caption);flex:none}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:20;border:1px solid var(--dsw-alias-border-inverted);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);box-shadow:var(--dsw-shadow-lv3);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-radius:12px;flex-direction:column;padding:4px;display:flex;position:absolute;bottom:calc(100% + 8px);right:0;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_description{color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px;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}";
344
+ 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{color:var(--dsw-alias-label-caption);flex:none}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:20;border:1px solid var(--dsw-alias-border-inverted);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);box-shadow:var(--dsw-shadow-lv3);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-radius:12px;flex-direction:column;padding:4px;display:flex;position:absolute;bottom:calc(100% + 8px);right:0;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}";
235
345
  const tagId = "@deepseek-ai/dsh-client-ui-model-selection/ModelSelect.module.css";
236
346
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
237
347
  const tag = document.createElement("style");
@@ -248,7 +358,6 @@ window.__ModuleLoader__.load({
248
358
  "check": "_7KE1Ra_check",
249
359
  "chevron": "_7KE1Ra_chevron",
250
360
  "chevronOpen": "_7KE1Ra_chevronOpen",
251
- "description": "_7KE1Ra_description",
252
361
  "empty": "_7KE1Ra_empty",
253
362
  "error": "_7KE1Ra_error",
254
363
  "group": "_7KE1Ra_group",
@@ -319,20 +428,13 @@ window.__ModuleLoader__.load({
319
428
  }] : [], ...reasoning.efforts.map((effort) => ({
320
429
  key: `effort:${effort.id}`,
321
430
  effort: effort.id,
322
- label: effort.name,
323
- ...effort.description === void 0 ? {} : { description: effort.description }
431
+ label: effort.name
324
432
  }))], [reasoning, t]);
325
433
  const busy = state.status === "selecting";
326
434
  const reload = () => {
327
435
  lastActionRef.current = "load";
328
436
  load();
329
437
  };
330
- (0, react.useEffect)(() => {
331
- if (available) {
332
- lastActionRef.current = "load";
333
- load();
334
- }
335
- }, [available, load]);
336
438
  (0, react.useEffect)(() => {
337
439
  if (!open) return;
338
440
  const closeOutside = (event) => {
@@ -415,9 +517,10 @@ window.__ModuleLoader__.load({
415
517
  lastActionRef.current = "select";
416
518
  select(selection).then(settleSelection);
417
519
  };
418
- const modelLabel = currentChoice?.model.name ?? t("trigger.fallback");
520
+ const waiting = state.current === null && state.status === "loading";
521
+ const modelLabel = waiting ? t("trigger.loading") : currentChoice?.model.name ?? (state.current === null ? t("trigger.fallback") : `${state.current.provider}/${state.current.model}`);
419
522
  const triggerLabel = effortLabel === void 0 ? modelLabel : `${modelLabel} · ${effortLabel}`;
420
- const triggerAria = currentChoice === void 0 ? t("trigger.selectAria") : effortLabel === void 0 ? t("trigger.aria", { model: modelLabel }) : t("trigger.ariaEffort", {
523
+ const triggerAria = waiting ? t("trigger.loading") : state.current === null ? t("trigger.selectAria") : effortLabel === void 0 ? t("trigger.aria", { model: modelLabel }) : t("trigger.ariaEffort", {
421
524
  model: modelLabel,
422
525
  effort: effortLabel
423
526
  });
@@ -561,15 +664,12 @@ window.__ModuleLoader__.load({
561
664
  model: model.id
562
665
  });
563
666
  },
564
- children: [(0, react_jsx_runtime.jsxs)("span", {
667
+ children: [(0, react_jsx_runtime.jsx)("span", {
565
668
  className: ModelSelect_module_css_default.optionCopy,
566
- children: [(0, react_jsx_runtime.jsx)("span", {
669
+ children: (0, react_jsx_runtime.jsx)("span", {
567
670
  className: ModelSelect_module_css_default.modelName,
568
671
  children: model.name
569
- }), model.description !== void 0 && (0, react_jsx_runtime.jsx)("span", {
570
- className: ModelSelect_module_css_default.description,
571
- children: model.description
572
- })]
672
+ })
573
673
  }), (0, react_jsx_runtime.jsx)("span", {
574
674
  className: ModelSelect_module_css_default.check,
575
675
  children: selected ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
@@ -605,15 +705,12 @@ window.__ModuleLoader__.load({
605
705
  onClick: () => {
606
706
  chooseEffort(level.effort);
607
707
  },
608
- children: [(0, react_jsx_runtime.jsxs)("span", {
708
+ children: [(0, react_jsx_runtime.jsx)("span", {
609
709
  className: ModelSelect_module_css_default.optionCopy,
610
- children: [(0, react_jsx_runtime.jsx)("span", {
710
+ children: (0, react_jsx_runtime.jsx)("span", {
611
711
  className: ModelSelect_module_css_default.modelName,
612
712
  children: level.label
613
- }), level.description !== void 0 && (0, react_jsx_runtime.jsx)("span", {
614
- className: ModelSelect_module_css_default.description,
615
- children: level.description
616
- })]
713
+ })
617
714
  }), (0, react_jsx_runtime.jsx)("span", {
618
715
  className: ModelSelect_module_css_default.check,
619
716
  children: effectiveEffort === level.effort ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : null
@@ -637,8 +734,8 @@ window.__ModuleLoader__.load({
637
734
  /**
638
735
  * `model` namespace dictionaries.
639
736
  *
640
- * `trigger.selectAria` reads identically to `trigger.fallback` today and is
641
- * still a separate key: the visible fallback label and the accessible name of
737
+ * `trigger.selectAria` intentionally matches `trigger.fallback` but remains a
738
+ * separate key: the visible fallback label and the accessible name of
642
739
  * an unset trigger are free to diverge per locale, and folding it into
643
740
  * `trigger.aria` would announce the degenerate "Select model, current Select
644
741
  * model".
@@ -648,6 +745,7 @@ window.__ModuleLoader__.load({
648
745
  "command.description": "选择本会话使用的模型",
649
746
  "option.loadError": "目录加载失败:{message}",
650
747
  "trigger.fallback": "选择模型",
748
+ "trigger.loading": "正在加载模型…",
651
749
  "trigger.selectAria": "选择模型",
652
750
  "trigger.aria": "选择模型,当前 {model}",
653
751
  "trigger.ariaEffort": "选择模型,当前 {model},推理等级 {effort}",
@@ -668,6 +766,7 @@ window.__ModuleLoader__.load({
668
766
  "command.description": "Select the model for this conversation",
669
767
  "option.loadError": "Catalog failed to load: {message}",
670
768
  "trigger.fallback": "Select model",
769
+ "trigger.loading": "Loading models…",
671
770
  "trigger.selectAria": "Select model",
672
771
  "trigger.aria": "Select model, current {model}",
673
772
  "trigger.ariaEffort": "Select model, current {model}, reasoning effort {effort}",
@@ -696,7 +795,7 @@ window.__ModuleLoader__.load({
696
795
  id: rowId(group.id, model.id),
697
796
  label: model.name,
698
797
  detail: model.description !== void 0 ? `${group.name} · ${model.description}` : group.name,
699
- ...directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}
798
+ ...directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}
700
799
  });
701
800
  for (const failure of directory.failures) rows.push({
702
801
  id: `failure/${failure.id}`,
@@ -728,11 +827,11 @@ window.__ModuleLoader__.load({
728
827
  /** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
729
828
  const inject = [
730
829
  "commandUi",
731
- "connection",
732
830
  "locale",
733
831
  "sessions",
734
832
  "slots",
735
- "remote"
833
+ "remote",
834
+ "remote.session"
736
835
  ];
737
836
  /**
738
837
  * Client plugin body: mount ModelDirectoryResolver, register the `model` dictionaries,
@@ -0,0 +1,38 @@
1
+ /** One Host-generation model catalog shared by every Session selector. */
2
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
3
+ import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client';
4
+ import { type SnapshotStore } from '@deepseek-ai/dsh-client-store';
5
+ /** Observable lifecycle of the shared model catalog. */
6
+ export interface ModelCatalogState {
7
+ value: ModelCatalog | null;
8
+ status: 'idle' | 'loading' | 'ready' | 'error';
9
+ error: string | null;
10
+ }
11
+ /** Loads at most one model catalog for the current Host generation. */
12
+ export declare class ModelCatalogDirectory {
13
+ private readonly ctx;
14
+ /** Current shared catalog value and load lifecycle. */
15
+ readonly store: SnapshotStore<ModelCatalogState>;
16
+ private generation;
17
+ private inflight;
18
+ /**
19
+ * @param ctx - the providing plugin's context, whose `remote.session`
20
+ * namespace carries the Host-generation catalog.
21
+ */
22
+ constructor(ctx: ClientContext);
23
+ /**
24
+ * Return the current generation's catalog, sharing its one in-flight load.
25
+ * @returns the loaded global catalog.
26
+ */
27
+ load(): Promise<ModelCatalog>;
28
+ /**
29
+ * Invalidate the loaded catalog; the next explicit menu read reloads it.
30
+ * @param clear - whether values from the previous Host generation must be hidden.
31
+ */
32
+ private invalidate;
33
+ /** Invalidate and reload the catalog after a Host-side model input changes. */
34
+ refresh(): void;
35
+ /** Clear Host-specific values and load the replacement Host generation. */
36
+ resetGeneration(): void;
37
+ }
38
+ //# sourceMappingURL=catalog.d.ts.map
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Per-session model directory: the ONE state both selection entries share.
3
- * The /model popup and the composer-seat selector load through the same
4
- * controller and submit through the same selectModel call, so the host stays
5
- * the single fact source and the store is one shared echo — a switch made in
6
- * either entry is what the other shows next.
3
+ * The /model popup and composer seat combine one shared Host catalog with the
4
+ * Session's durable selection projection, then submit through the same
5
+ * selectModel call. A switch made in either entry updates this shared state.
7
6
  */
8
- import type { IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels } from '@deepseek-ai/dsh-api-remotes/client';
9
- import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
7
+ import type { ModelCatalogFailure, ModelProviderGroup, ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types';
8
+ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
9
+ import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol';
10
+ import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store';
11
+ import type { ModelCatalogDirectory } from './catalog.ts';
10
12
  /** Directory snapshot both entries render from. */
11
13
  export interface ModelDirectoryState {
12
- /** Model selection the host reports for the next assembled step; null before the first load. */
14
+ /** Effective selection: durable next-request projection, then Host default. */
13
15
  current: ModelSelection | null;
14
16
  /**
15
17
  * Whether an adapter serves the current selection's provider, as the host reports
@@ -33,38 +35,43 @@ export declare class ModelDirectory {
33
35
  private readonly sessions;
34
36
  private readonly sessionId;
35
37
  private readonly available;
38
+ private readonly catalog;
39
+ private readonly projected;
36
40
  /** The shared snapshot both entries render from (uSES-safe store). */
37
41
  readonly store: SnapshotStore<ModelDirectoryState>;
38
- /** Latest operation wins; an older response never overwrites a newer one. */
42
+ /** Latest selection operation wins; an older response never overwrites a newer one. */
39
43
  private generation;
40
44
  private disposed;
45
+ private resolved;
46
+ private readonly unsubscribeCatalog;
47
+ private readonly unsubscribeSelection;
41
48
  /**
42
49
  * @param sessions - the session wire face (captured from the plugin's root connection).
43
50
  * @param sessionId - the owning session.
44
51
  * @param available - whether this session may use Agent-bound model RPCs.
52
+ * @param catalog - Host-generation catalog shared by every Session.
53
+ * @param projected - durable model selection projected from Session history.
45
54
  */
46
- constructor(sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>, sessionId: SessionId, available: () => boolean);
55
+ constructor(sessions: Pick<TypertClientRemote['session'], 'selectModel'>, sessionId: SessionId, available: () => boolean, catalog: ModelCatalogDirectory, projected: ObservableSnapshot<unknown>);
47
56
  /**
48
- * Refresh the advisory directory (both entries call this on open).
49
- * Failure preserves the last good groups and current selection.
57
+ * Ensure the Host generation's shared advisory catalog is loaded.
50
58
  * @returns the fresh directory value.
51
59
  */
52
- load(): Promise<SessionModels>;
60
+ load(): Promise<ModelDirectoryState>;
53
61
  /**
54
- * Select the complete provider/model/reasoning selection (both entries submit through here). Success
55
- * updates the shared current; failure surfaces on the store and throws so
56
- * each entry's own retry surface engages.
62
+ * Select the complete provider/model/reasoning selection. The durable
63
+ * projection frame updates the shared current; failures surface on the store
64
+ * and throw so each entry's own retry surface engages.
57
65
  * @param selection - provider, provider-owned model id, and optional adapter-owned effort.
58
66
  */
59
67
  select(selection: ModelSelection): Promise<void>;
60
68
  /**
61
- * Drop the previous Host generation's projection and repull it. Clearing
62
- * first prevents an unconsumed process-local selection from being displayed
63
- * while the restarted Host has restored the last logged model selection.
69
+ * Invalidate an in-flight selection response from the previous Host generation.
64
70
  */
65
71
  resetConnected(): void;
66
72
  /** Scope teardown: late settlements lose write access to the store. */
67
73
  dispose(): void;
68
74
  private assertAvailable;
75
+ private syncInputs;
69
76
  }
70
77
  //# sourceMappingURL=directory.d.ts.map
@@ -1,4 +1,4 @@
1
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
1
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
2
2
  import { type ModelKey } from './locales.ts';
3
3
  export { ModelDirectory } from './directory.ts';
4
4
  export type { ModelDirectoryState } from './directory.ts';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `model` namespace dictionaries.
3
3
  *
4
- * `trigger.selectAria` reads identically to `trigger.fallback` today and is
5
- * still a separate key: the visible fallback label and the accessible name of
4
+ * `trigger.selectAria` intentionally matches `trigger.fallback` but remains a
5
+ * separate key: the visible fallback label and the accessible name of
6
6
  * an unset trigger are free to diverge per locale, and folding it into
7
7
  * `trigger.aria` would announce the degenerate "Select model, current Select
8
8
  * model".
@@ -12,6 +12,7 @@ export declare const zh: {
12
12
  'command.description': string;
13
13
  'option.loadError': string;
14
14
  'trigger.fallback': string;
15
+ 'trigger.loading': string;
15
16
  'trigger.selectAria': string;
16
17
  'trigger.aria': string;
17
18
  'trigger.ariaEffort': string;
@@ -34,6 +35,7 @@ export declare const en: {
34
35
  'command.description': string;
35
36
  'option.loadError': string;
36
37
  'trigger.fallback': string;
38
+ 'trigger.loading': string;
37
39
  'trigger.selectAria': string;
38
40
  'trigger.aria': string;
39
41
  'trigger.ariaEffort': string;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { Service } from '@deepseek-ai/cordis';
16
16
  import type { Context } from '@deepseek-ai/cordis';
17
- import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
17
+ import type { SessionId } from '@deepseek-ai/dsh-session/types';
18
18
  import { ModelDirectory } from './directory.ts';
19
19
  declare module '@deepseek-ai/cordis' {
20
20
  interface Context {
@@ -25,6 +25,7 @@ declare module '@deepseek-ai/cordis' {
25
25
  export declare class ModelDirectoryResolver extends Service {
26
26
  static inject: string[];
27
27
  private readonly live;
28
+ private readonly catalog;
28
29
  /** Localized composer-block copy; this plugin owns the string it raises. */
29
30
  private readonly blockReason;
30
31
  /**
@@ -5,7 +5,7 @@
5
5
  * merge lives here.
6
6
  */
7
7
  import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client';
8
- import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
8
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
9
9
  import type { ModelDirectoryState } from './directory.ts';
10
10
  /** Injected business face of the composer model seat. */
11
11
  export interface ModelSelectInjected {
@@ -13,7 +13,7 @@ export interface ModelSelectInjected {
13
13
  available: boolean;
14
14
  /** The session's shared directory store (same instance the /model popup reads). */
15
15
  directory: SnapshotStore<ModelDirectoryState>;
16
- /** Refresh the advisory directory (fire-and-forget; errors land on the store). */
16
+ /** Ensure the shared advisory catalog is loaded (errors land on the store). */
17
17
  load: () => void;
18
18
  /**
19
19
  * Select a complete provider/model/reasoning selection.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-model-selection",
3
- "description": "Model selection: the /model popupSelect over session.models / session.selectModel",
4
- "version": "0.1.1-rc.2",
3
+ "description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
4
+ "version": "0.1.2-alpha.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,8 +32,8 @@
32
32
  "dsh": {
33
33
  "client": {
34
34
  "inject": [
35
+ "@deepseek-ai/dsh-api-session-controller",
35
36
  "@deepseek-ai/dsh-client-locale",
36
- "@deepseek-ai/dsh-client-runtime",
37
37
  "@deepseek-ai/dsh-client-ui-commands",
38
38
  "@deepseek-ai/dsh-api-remotes"
39
39
  ],
@@ -42,31 +42,27 @@
42
42
  },
43
43
  "license": "MIT",
44
44
  "peerDependencies": {
45
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
46
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
47
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
48
- "@deepseek-ai/dsh-client-ui-commands": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
52
- "@deepseek-ai/cordis": "^4.0.1",
53
- "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2"
45
+ "@deepseek-ai/cordis": "^4.0.2"
54
46
  },
55
47
  "devDependencies": {
56
48
  "@types/react": "~18.3.1",
57
49
  "react": "^18.2.0",
58
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
62
- "@deepseek-ai/dsh-client-test-runtime": "^0.1.1-rc.2",
63
- "@deepseek-ai/dsh-client-ui-commands": "^0.1.1-rc.2",
64
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
65
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.1-rc.2",
66
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
67
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
68
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
69
- "@deepseek-ai/cordis": "^4.0.1"
50
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.3",
51
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.3",
52
+ "@deepseek-ai/dsh-client-ui-commands": "^0.1.2-alpha.3",
53
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.3",
54
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.3",
55
+ "@deepseek-ai/dsh-client-test-runtime": "^0.1.2-alpha.3",
56
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.2-alpha.3",
57
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.3",
58
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.3",
59
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
60
+ "@deepseek-ai/cordis": "^4.0.2",
61
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.3",
62
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
63
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.3",
64
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.2-alpha.3",
65
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.3"
70
66
  },
71
67
  "files": [
72
68
  "lib/index.js",