dsh-switchman 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2441 @@
1
+ /** Client half of dsh-switchman.
2
+ *
3
+ * One self-contained entry (the client-module system serves exactly one
4
+ * bundle per package; sibling files cannot be synchronously required).
5
+ *
6
+ * Surfaces, all reading the shared "dsh-switchman" locale namespace (zh/en):
7
+ * - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
8
+ * - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
9
+ * - Phase 1: a `settings.section` page with a "Language" card:
10
+ * conversation / code-comment / document language preferences.
11
+ * - Phase 2: a "Dispatch pools & ranking" card on the same page: six lane
12
+ * pools and a capability ranking edited against the live model catalog
13
+ * (served by the Host route family), plus the enforcement mode. Every
14
+ * field shares one staged draft and one atomic fenced save.
15
+ * - Phase 3: a "Context watermark" card on the same page: tiered token
16
+ * thresholds (soft/hard/force), the per-read budget, hard-tier behavior,
17
+ * automatic handover, and the subagent context cap — number inputs with
18
+ * per-field minimum plus soft<hard<force order validation, folded into
19
+ * the same staged draft and one atomic save.
20
+ */
21
+
22
+ window.__ModuleLoader__.load({
23
+ id: 'dsh-switchman',
24
+ factory(require) {
25
+ const React = require('react');
26
+ const h = React.createElement;
27
+
28
+ /** Locale namespace shared by every dsh-switchman client surface. */
29
+ const NS = 'dsh-switchman';
30
+
31
+ /** Settings namespace this bundle's Host half owns (= cordis name). */
32
+ const HOST_NS = 'dsh-switchman';
33
+
34
+ // ------------------------------------------------------------------
35
+ // Preference model (Phase 1 languages + Phase 2 dispatch)
36
+ // ------------------------------------------------------------------
37
+
38
+ /** Selectable languages. `value` is the stored settings value (BCP-style
39
+ * tag, ≤48 chars per the Host schema convention); `label` is the native
40
+ * name, identical in every UI language. */
41
+ const LANGUAGES = [
42
+ { value: 'en', label: 'English' },
43
+ { value: 'zh-CN', label: '简体中文' },
44
+ { value: 'zh-TW', label: '繁體中文' },
45
+ { value: 'ja', label: '日本語' },
46
+ { value: 'ko', label: '한국어' },
47
+ { value: 'es', label: 'Español' },
48
+ { value: 'fr', label: 'Français' },
49
+ { value: 'de', label: 'Deutsch' },
50
+ { value: 'ru', label: 'Русский' },
51
+ ];
52
+
53
+ /** Sentinel select value standing for a hand-typed language. */
54
+ const CUSTOM = '__custom__';
55
+
56
+ /** Max length of one language value (Host-side convention). */
57
+ const LANG_MAX = 48;
58
+
59
+ /** Host-route bridge: the restricted dynamic-module runtime has no
60
+ * direct network beyond same-origin fetch, so every settings
61
+ * read/write and the model catalog go through the Host half's
62
+ * /api/dsh-switchman family. Document-relative on purpose — the
63
+ * harness serves the GUI with <base href="./">. */
64
+ const API_BASE = 'api/dsh-switchman/';
65
+ async function apiRequest(resource, options) {
66
+ try {
67
+ const response = await fetch(`${API_BASE}${resource}`, {
68
+ method: options?.method ?? 'GET',
69
+ headers:
70
+ options?.body === undefined
71
+ ? new Headers()
72
+ : new Headers({ 'content-type': 'application/json' }),
73
+ body: options?.body === undefined ? undefined : JSON.stringify(options.body),
74
+ });
75
+ let body;
76
+ try {
77
+ body = await response.json();
78
+ } catch {
79
+ body = undefined;
80
+ }
81
+ return response.ok
82
+ ? { ok: true, value: body }
83
+ : { ok: false, status: response.status, value: body };
84
+ } catch {
85
+ return { ok: false, status: 0 };
86
+ }
87
+ }
88
+ const api = {
89
+ get: (resource) => apiRequest(resource),
90
+ post: (resource, body) => apiRequest(resource, { method: 'POST', body }),
91
+ };
92
+
93
+ /** The three language fields, in display order. */
94
+ const LANG_FIELDS = ['langConversation', 'langComments', 'langDocs'];
95
+
96
+ /** Capability tiers, strongest first ('' = unanchored). */
97
+ const TIERS = ['S', 'A', 'B', 'C'];
98
+
99
+ /** Enforcement modes (dictionary keys: enforceOff/Advice/Enforce). */
100
+ const ENFORCE_MODES = ['off', 'advice', 'enforce'];
101
+
102
+ /** The six dispatch lanes: settings field per lane, display order.
103
+ * manualField = the per-lane manual-order toggle (true = stored
104
+ * array order IS the dispatch priority). */
105
+ const POOL_FIELDS = [
106
+ { field: 'poolEconomy', manualField: 'poolEconomyManual', effortsField: 'poolEconomyEfforts', lane: 'economy' },
107
+ { field: 'poolMechanical', manualField: 'poolMechanicalManual', effortsField: 'poolMechanicalEfforts', lane: 'mechanical' },
108
+ { field: 'poolMain', manualField: 'poolMainManual', effortsField: 'poolMainEfforts', lane: 'main' },
109
+ { field: 'poolHard', manualField: 'poolHardManual', effortsField: 'poolHardEfforts', lane: 'hard' },
110
+ { field: 'poolVision', manualField: 'poolVisionManual', effortsField: 'poolVisionEfforts', lane: 'vision' },
111
+ { field: 'poolReview', manualField: 'poolReviewManual', effortsField: 'poolReviewEfforts', lane: 'review' },
112
+ ];
113
+
114
+ /** Numeric watermark fields: settings field, schema minimum, and the
115
+ * Host-side default (also the fallback for malformed stored values). */
116
+ const WM_NUMBER_FIELDS = [
117
+ { field: 'wmSoftTokens', min: 1000, def: 50000 },
118
+ { field: 'wmHardTokens', min: 2000, def: 90000 },
119
+ { field: 'wmForceTokens', min: 3000, def: 130000 },
120
+ { field: 'wmReadBudgetTokens', min: 200, def: 1500 },
121
+ { field: 'wmSubagentForceTokens', min: 0, def: 0 },
122
+ ];
123
+
124
+ /** The three thresholds that must keep a strict ascending order. */
125
+ const WM_ORDER_FIELDS = ['wmSoftTokens', 'wmHardTokens', 'wmForceTokens'];
126
+
127
+ /** Dictionary keys per numeric watermark row. */
128
+ const WM_ROW_KEYS = {
129
+ wmSoftTokens: { label: 'wmSoftLabel', help: 'wmSoftHelp' },
130
+ wmHardTokens: { label: 'wmHardLabel', help: 'wmHardHelp' },
131
+ wmForceTokens: { label: 'wmForceLabel', help: 'wmForceHelp' },
132
+ wmReadBudgetTokens: { label: 'wmReadBudgetLabel', help: 'wmReadBudgetHelp' },
133
+ wmSubagentForceTokens: {
134
+ label: 'wmSubagentForceLabel',
135
+ help: 'wmSubagentForceHelp',
136
+ },
137
+ };
138
+
139
+ /** Every settings field this page edits, in mutate order. */
140
+ const ALL_FIELDS = [
141
+ ...LANG_FIELDS,
142
+ ...POOL_FIELDS.map(({ field }) => field),
143
+ ...POOL_FIELDS.map(({ manualField }) => manualField),
144
+ ...POOL_FIELDS.map(({ effortsField }) => effortsField),
145
+ 'modelRank',
146
+ 'dispatchEnforce',
147
+ ...WM_NUMBER_FIELDS.map(({ field }) => field),
148
+ 'wmDenyMode',
149
+ 'wmAutoHandover',
150
+ 'wmSubagentCap',
151
+ ];
152
+
153
+ /** Stable identity of one provider/model route. */
154
+ const routeKey = (route) => `${route.provider}\0${route.model}`;
155
+
156
+ /** One well-formed {provider, model} route, or null. */
157
+ function sanitizeRoute(route) {
158
+ if (route === null || typeof route !== 'object') return null;
159
+ if (typeof route.provider !== 'string' || route.provider === '') return null;
160
+ if (typeof route.model !== 'string' || route.model === '') return null;
161
+ return { provider: route.provider, model: route.model };
162
+ }
163
+
164
+ /** One well-formed rank entry ({provider, model[, tier]}), or null;
165
+ * an unknown tier is dropped rather than the whole entry. */
166
+ function sanitizeRankEntry(entry) {
167
+ const route = sanitizeRoute(entry);
168
+ if (route === null) return null;
169
+ return typeof entry.tier === 'string' && TIERS.includes(entry.tier)
170
+ ? { ...route, tier: entry.tier }
171
+ : route;
172
+ }
173
+
174
+ /** 思考强度 fallback 档位(模型拿不到 wire 元数据时用);
175
+ * 真实列表以 wire reasoning.efforts 为准。 */
176
+ const EFFORTS = ['low', 'medium', 'high'];
177
+
178
+ /** 一条格式良好的 effort 条目({provider, model, effort}),或 null。 */
179
+ function sanitizeEffortEntry(entry) {
180
+ const route = sanitizeRoute(entry);
181
+ if (route === null) return null;
182
+ return typeof entry.effort === 'string' && entry.effort !== ''
183
+ ? { ...route, effort: entry.effort }
184
+ : null;
185
+ }
186
+
187
+ /** Is `value` one of the predefined candidates? */
188
+ function isCandidate(value) {
189
+ return LANGUAGES.some((language) => language.value === value);
190
+ }
191
+
192
+ /** Human display for a stored value: "简体中文 (zh-CN)", or the raw
193
+ * string when it is a custom value. */
194
+ function formatLanguage(value) {
195
+ if (value === '') return '';
196
+ const known = LANGUAGES.find((language) => language.value === value);
197
+ return known === undefined ? value : `${known.label} (${known.value})`;
198
+ }
199
+
200
+ /** Suggested conversation language for one UI locale id: 'zh' maps to
201
+ * the zh-CN candidate, a matching candidate id maps to itself, anything
202
+ * else falls back to English. Display-only advice — never written. */
203
+ function suggestFromUiLocale(id) {
204
+ if (id === 'zh') return 'zh-CN';
205
+ return isCandidate(id) ? id : 'en';
206
+ }
207
+
208
+ /** Read every field this page edits off a form snapshot, coerced to
209
+ * shape ('' / [] / numeric / boolean / enum defaults when unset or
210
+ * malformed). */
211
+ function readCurrent(snapshot) {
212
+ // Form state carries the Host snapshot under `values` (the
213
+ // original configForms shape used `.value`; keep a fallback so
214
+ // a shape regression degrades instead of silently blanking).
215
+ const value = snapshot.values ?? snapshot.value;
216
+ const string = (field) =>
217
+ typeof value?.[field] === 'string' ? value[field] : '';
218
+ const routes = (field) =>
219
+ Array.isArray(value?.[field])
220
+ ? value[field].map(sanitizeRoute).filter(Boolean)
221
+ : [];
222
+ const efforts = (field) =>
223
+ Array.isArray(value?.[field])
224
+ ? value[field].map(sanitizeEffortEntry).filter(Boolean)
225
+ : [];
226
+ const number = ({ field, def }) =>
227
+ typeof value?.[field] === 'number' && Number.isFinite(value[field])
228
+ ? value[field]
229
+ : def;
230
+ const bool = (field, def) =>
231
+ typeof value?.[field] === 'boolean' ? value[field] : def;
232
+ return {
233
+ langConversation: string('langConversation'),
234
+ langComments: string('langComments'),
235
+ langDocs: string('langDocs'),
236
+ ...Object.fromEntries(
237
+ POOL_FIELDS.map(({ field }) => [field, routes(field)]),
238
+ ),
239
+ ...Object.fromEntries(
240
+ POOL_FIELDS.map(({ field, manualField }) => [
241
+ manualField,
242
+ bool(manualField, false),
243
+ ]),
244
+ ),
245
+ ...Object.fromEntries(
246
+ POOL_FIELDS.map(({ effortsField }) => [
247
+ effortsField,
248
+ efforts(effortsField),
249
+ ]),
250
+ ),
251
+ modelRank: Array.isArray(value?.modelRank)
252
+ ? value.modelRank.map(sanitizeRankEntry).filter(Boolean)
253
+ : [],
254
+ dispatchEnforce: ENFORCE_MODES.includes(value?.dispatchEnforce)
255
+ ? value.dispatchEnforce
256
+ : 'advice',
257
+ ...Object.fromEntries(
258
+ WM_NUMBER_FIELDS.map((spec) => [spec.field, number(spec)]),
259
+ ),
260
+ wmDenyMode: value?.wmDenyMode === 'deny' ? 'deny' : 'cap',
261
+ wmAutoHandover: bool('wmAutoHandover', true),
262
+ wmSubagentCap: bool('wmSubagentCap', true),
263
+ };
264
+ }
265
+
266
+ /** Canonical serialization for equality: auto pools compare as sets
267
+ * (order irrelevant) while manual pools compare as ordered sequences
268
+ * (their order IS the dispatch priority), the ranking as an ordered
269
+ * tier-annotated sequence, everything else verbatim (numbers as
270
+ * numbers — never their input-string forms). */
271
+ function canonical(values) {
272
+ return JSON.stringify({
273
+ langConversation: values.langConversation,
274
+ langComments: values.langComments,
275
+ langDocs: values.langDocs,
276
+ ...Object.fromEntries(
277
+ POOL_FIELDS.map(({ field, manualField }) => [
278
+ field,
279
+ values[manualField]
280
+ ? values[field].map(routeKey)
281
+ : values[field].map(routeKey).sort(),
282
+ ]),
283
+ ),
284
+ ...Object.fromEntries(
285
+ POOL_FIELDS.map(({ manualField }) => [
286
+ manualField,
287
+ values[manualField],
288
+ ]),
289
+ ),
290
+ ...Object.fromEntries(
291
+ POOL_FIELDS.map(({ effortsField }) => [
292
+ effortsField,
293
+ values[effortsField]
294
+ .map((entry) => `${routeKey(entry)}\0${entry.effort}`)
295
+ .sort()
296
+ .join('|'),
297
+ ]),
298
+ ),
299
+ modelRank: values.modelRank
300
+ .map((entry) => `${routeKey(entry)}\0${entry.tier ?? ''}`)
301
+ .join('|'),
302
+ dispatchEnforce: values.dispatchEnforce,
303
+ ...Object.fromEntries(
304
+ WM_NUMBER_FIELDS.map(({ field }) => [field, values[field]]),
305
+ ),
306
+ wmDenyMode: values.wmDenyMode,
307
+ wmAutoHandover: values.wmAutoHandover,
308
+ wmSubagentCap: values.wmSubagentCap,
309
+ });
310
+ }
311
+
312
+ /** Deep-enough equality across every edited field. */
313
+ function sameValues(left, right) {
314
+ return canonical(left) === canonical(right);
315
+ }
316
+
317
+ /** Index one catalog answer: route key → display metadata. Models are
318
+ * keyed by `id` (the sessionController wire shape: {id, name}) with
319
+ * the group id as provider; the flattened `model` shape is accepted
320
+ * too. Groups keep their wire order. */
321
+ function indexCatalog(groups) {
322
+ const index = new Map();
323
+ for (const group of Array.isArray(groups) ? groups : []) {
324
+ const providerName = group.name ?? group.id ?? '';
325
+ for (const model of Array.isArray(group.models) ? group.models : []) {
326
+ const provider = model.provider ?? group.id;
327
+ const modelId = model.model ?? model.id;
328
+ if (typeof provider !== 'string' || typeof modelId !== 'string')
329
+ continue;
330
+ index.set(routeKey({ provider, model: modelId }), {
331
+ provider,
332
+ model: modelId,
333
+ providerName: providerName || provider,
334
+ modelName: model.name ?? modelId,
335
+ reasoning:
336
+ model.reasoning !== null &&
337
+ typeof model.reasoning === 'object'
338
+ ? model.reasoning
339
+ : undefined,
340
+ });
341
+ }
342
+ }
343
+ return index;
344
+ }
345
+
346
+ /** Checkbox rows for one pool: every live catalog model, checked or
347
+ * not, plus this pool's saved routes that left the catalog (still
348
+ * removable — mirrors the shipped model-selection page). */
349
+ function poolRows(pool, catalogIndex) {
350
+ const selected = new Set(pool.map(routeKey));
351
+ const rows = [];
352
+ let providerName = null;
353
+ for (const meta of catalogIndex.values()) {
354
+ if (meta.providerName !== providerName) {
355
+ providerName = meta.providerName;
356
+ rows.push({ header: providerName });
357
+ }
358
+ rows.push({ ...meta, selected: selected.has(routeKey(meta)) });
359
+ }
360
+ const extras = pool
361
+ .filter((route) => !catalogIndex.has(routeKey(route)))
362
+ .map((route) => ({
363
+ provider: route.provider,
364
+ model: route.model,
365
+ providerName: route.provider,
366
+ modelName: route.model,
367
+ available: false,
368
+ selected: true,
369
+ }));
370
+ return { rows, extras };
371
+ }
372
+
373
+ /** The rank editor's display list: saved ranking first (stored order),
374
+ * then every universe member not yet ranked in catalog order with
375
+ * saved-unavailable last. Universe = all pool selections ∪ the saved
376
+ * ranking (opencode semantics the Host side consumes). */
377
+ function rankDisplay(values, catalogIndex) {
378
+ const ranked = values.modelRank.map((entry) => ({ ...entry }));
379
+ const rankedKeys = new Set(ranked.map(routeKey));
380
+ const universe = new Map();
381
+ for (const { field } of POOL_FIELDS)
382
+ for (const route of values[field]) universe.set(routeKey(route), route);
383
+ for (const entry of ranked) universe.set(routeKey(entry), entry);
384
+ const tail = [];
385
+ for (const meta of catalogIndex.values()) {
386
+ const key = routeKey(meta);
387
+ if (!rankedKeys.has(key) && universe.has(key))
388
+ tail.push({ provider: meta.provider, model: meta.model });
389
+ }
390
+ for (const [key, route] of universe)
391
+ if (!rankedKeys.has(key) && !catalogIndex.has(key))
392
+ tail.push({ provider: route.provider, model: route.model });
393
+ return [...ranked, ...tail];
394
+ }
395
+
396
+ /** Serialize a display list back into the stored modelRank shape
397
+ * (tier omitted when unset). */
398
+ const rankValue = (display) =>
399
+ display.map((entry) => ({
400
+ provider: entry.provider,
401
+ model: entry.model,
402
+ ...(entry.tier ? { tier: entry.tier } : {}),
403
+ }));
404
+
405
+ // ------------------------------------------------------------------
406
+ // Dictionaries
407
+ // ------------------------------------------------------------------
408
+
409
+ /** zh/en dictionaries; keys are stable, values follow the UI language. */
410
+ const DICTS = {
411
+ zh: {
412
+ badge: '自主团队',
413
+ badgeTip:
414
+ 'dsh-switchman:四个内置预设(standard / ptc / minimal / cordis)已注入自主智能体团队调度规程',
415
+ settingsTitle: 'dsh-switchman',
416
+ settingsDescription: '配置 dsh-switchman 的各项偏好。',
417
+ langSectionTitle: '语言偏好',
418
+ langSectionDescription:
419
+ '控制智能体的对话、代码注释与文档撰写语言。',
420
+ langConversationLabel: '对话语言',
421
+ langConversationHelp:
422
+ '智能体回复与讨论使用的语言;未设置时会在首次使用时询问一次并记住。',
423
+ langCommentsLabel: '代码注释语言',
424
+ langCommentsHelp: '智能体编写代码注释使用的语言。',
425
+ langDocsLabel: '文档语言',
426
+ langDocsHelp: '智能体撰写文档使用的语言。',
427
+ followConversation: '跟随对话语言',
428
+ notSetAsk: '未设置(首次使用时询问)',
429
+ customLanguage: '自定义…',
430
+ customLanguagePlaceholder: '语言名称或 BCP 标签',
431
+ customLanguageRequired: '请输入自定义语言(1–48 个字符)。',
432
+ currentValue: '当前:{value}',
433
+ currentValueUnset: '当前:未设置',
434
+ suggestedValue: '建议:{value}',
435
+ save: '保存',
436
+ saving: '保存中…',
437
+ discard: '放弃修改',
438
+ saveFailed: '本部署没有接受这些值,已保留供你修改。',
439
+ saveConflict: '设置已在其他位置更新。请放弃修改后重试。',
440
+ formLoading: '正在加载设置…',
441
+ formUnavailable: '该插件当前未加载,暂时无法配置。',
442
+ formReadOnly: '本部署的设置为只读。',
443
+ dispatchSectionTitle: '派发池与模型排序',
444
+ dispatchSectionDescription:
445
+ '按任务复杂度把模型分组进六个派发池,并维护能力排序;仅影响 dsh-switchman 的委派调度。',
446
+ dispatchProgress: '已配置 {pools}/6 池 · 排名 {ranked} 项 · 模式 {mode}',
447
+ poolEconomyTitle: '轻量池 · economy',
448
+ poolEconomyDesc: '轻量批量与低复杂度任务',
449
+ poolMechanicalTitle: '机械池 · mechanical',
450
+ poolMechanicalDesc: '机械改写与模板化操作',
451
+ poolMainTitle: '主力池 · main',
452
+ poolMainDesc: '常规编码与日常任务',
453
+ poolHardTitle: '高难池 · hard',
454
+ poolHardDesc: '高难推理与大规模重构',
455
+ poolVisionTitle: '多模态池 · vision',
456
+ poolVisionDesc: '图像等多模态输入任务',
457
+ poolReviewTitle: '复审池 · review',
458
+ poolReviewDesc: '只读复审与独立验证',
459
+ poolSelectAll: '全选',
460
+ poolClear: '清空',
461
+ poolEmpty: '暂无候选模型',
462
+ poolManualLabel: '手动序',
463
+ poolManualHelp:
464
+ '开启后按列表顺序派发(首个即首选);未开启按能力排序自动排。',
465
+ poolManualSelected: '已选 · 派发优先序',
466
+ poolManualAddable: '可添加',
467
+ poolMoveUp: '上移',
468
+ poolMoveDown: '下移',
469
+ poolRemoveOne: '移除',
470
+ poolEffortFollow: '跟随泳道',
471
+ poolEffortLabel: '思考强度',
472
+ poolEffortHelp:
473
+ '手动指定该模型的思考强度,派发时优先于泳道默认',
474
+ catalogLoading: '正在加载模型目录…',
475
+ catalogLoadFailed: '无法加载模型目录。',
476
+ catalogRetry: '重试',
477
+ catalogPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。',
478
+ catalogEmpty: '当前没有模型提供方公布模型。',
479
+ catalogUnavailableGroup: '已保存但当前不可用',
480
+ catalogUnavailable: '当前不可用',
481
+ rankTitle: '能力排序',
482
+ rankDescription:
483
+ '序号即能力序(最强在前),可为每项锚定 S/A/B/C 档。列表为六池选中 ∪ 已有排序;任意编辑都会固化当前显示顺序。',
484
+ rankEmpty: '先在上方池中勾选模型,排序列表会自动出现。',
485
+ rankTier: '档位',
486
+ rankTierNone: '—',
487
+ rankMoveUp: '上移',
488
+ rankMoveDown: '下移',
489
+ rankRemove: '移出排序',
490
+ enforceLabel: '执行模式',
491
+ enforceOff: '关闭',
492
+ enforceAdvice: '建议',
493
+ enforceEnforce: '强制',
494
+ enforceHelpOff: '不向委派注入任何池子指引。',
495
+ enforceHelpAdvice: '池子指引作为建议注入,模型可自行偏离。',
496
+ enforceHelpEnforce: '池子指引作为硬约束注入,偏离会被拒绝。',
497
+ wmSectionTitle: '上下文水位',
498
+ wmSectionDescription:
499
+ '按 token 用量分级收紧行为:建议、限读与收尾、自动交接。',
500
+ wmSoftLabel: '建议水位(soft)',
501
+ wmSoftHelp: '达到后出现横幅提示,并建议委派与整理上下文。',
502
+ wmHardLabel: '硬水位(hard)',
503
+ wmHardHelp: '达到后读取预算收紧,并建议收尾当前工作。',
504
+ wmForceLabel: '强制水位(force)',
505
+ wmForceHelp: '达到后建议备份并移交压缩(可设为自动)。',
506
+ wmReadBudgetLabel: '单次读取预算',
507
+ wmReadBudgetHelp: '每次读取的 token 预算;每轮上限为其两倍。',
508
+ wmNumberInvalid: '请输入不小于 {min} 的整数。',
509
+ wmOrderInvalid: '三档水位需严格递增:soft < hard < force。',
510
+ wmDenyModeLabel: '硬档行为',
511
+ wmDenyModeCap: '限流放行',
512
+ wmDenyModeDeny: '直接拒绝',
513
+ wmDenyModeHelpCap: '超出读预算的调用放行,但记录警示。',
514
+ wmDenyModeHelpDeny: '超出读预算的调用直接拒绝。',
515
+ wmAutoHandoverLabel: '自动交接',
516
+ wmAutoHandoverHelp: '达到强制水位时自动备份并移交压缩,无需手动确认。',
517
+ wmSubagentTitle: '子智能体',
518
+ wmSubagentCapLabel: '子智能体上下文上限',
519
+ wmSubagentCapHelp:
520
+ '开启后子智能体可用下方独立强制阈值;关闭则完全跟随上方三档水位。',
521
+ wmSubagentForceLabel: '子智能体强制阈值',
522
+ wmSubagentForceHelp:
523
+ '仅覆盖子智能体的 force 档(soft/hard 与主会话共用);0 = 跟随上方强制水位。',
524
+ wmSubagentFollowForce: '跟随 force 档(0)',
525
+ wmCommands:
526
+ '命令:/ctx-pause 暂停水位干预 · /ctx-resume 恢复干预 · /ctx-handover 立即备份并交接压缩。',
527
+ },
528
+ en: {
529
+ badge: 'Team autonomy',
530
+ badgeTip:
531
+ 'dsh-switchman: the four built-in presets (standard / ptc / minimal / cordis) carry the autonomous Agent Teams doctrine',
532
+ settingsTitle: 'dsh-switchman',
533
+ settingsDescription: 'Configure dsh-switchman preferences.',
534
+ langSectionTitle: 'Language',
535
+ langSectionDescription:
536
+ 'Controls the language of agent replies, code comments, and authored documents.',
537
+ langConversationLabel: 'Conversation language',
538
+ langConversationHelp:
539
+ 'The language agents reply and discuss in; when unset, you are asked once on first use and the choice is remembered.',
540
+ langCommentsLabel: 'Code comment language',
541
+ langCommentsHelp: 'The language agents write code comments in.',
542
+ langDocsLabel: 'Document language',
543
+ langDocsHelp: 'The language agents author documents in.',
544
+ followConversation: 'Follow conversation language',
545
+ notSetAsk: 'Not set (ask on first use)',
546
+ customLanguage: 'Custom…',
547
+ customLanguagePlaceholder: 'Language name or BCP tag',
548
+ customLanguageRequired: 'Enter a custom language (1–48 characters).',
549
+ currentValue: 'Current: {value}',
550
+ currentValueUnset: 'Current: not set',
551
+ suggestedValue: 'Suggested: {value}',
552
+ save: 'Save',
553
+ saving: 'Saving…',
554
+ discard: 'Discard changes',
555
+ saveFailed:
556
+ 'The deployment did not accept these values; they were left for you to correct.',
557
+ saveConflict: 'Settings changed elsewhere. Discard your draft and try again.',
558
+ formLoading: 'Loading settings…',
559
+ formUnavailable: 'This plugin is not loaded, so it cannot be configured right now.',
560
+ formReadOnly: 'This deployment stores settings read-only.',
561
+ dispatchSectionTitle: 'Dispatch pools & model ranking',
562
+ dispatchSectionDescription:
563
+ 'Group models into six dispatch pools by task complexity and maintain a capability ranking; only affects dsh-switchman delegation.',
564
+ dispatchProgress: '{pools}/6 pools set · {ranked} ranked · mode {mode}',
565
+ poolEconomyTitle: 'Economy',
566
+ poolEconomyDesc: 'Lightweight batch and low-complexity tasks',
567
+ poolMechanicalTitle: 'Mechanical',
568
+ poolMechanicalDesc: 'Mechanical rewrites and templated operations',
569
+ poolMainTitle: 'Main',
570
+ poolMainDesc: 'Routine coding and everyday tasks',
571
+ poolHardTitle: 'Hard',
572
+ poolHardDesc: 'Hard reasoning and large-scale refactors',
573
+ poolVisionTitle: 'Vision',
574
+ poolVisionDesc: 'Multimodal (image) work',
575
+ poolReviewTitle: 'Review',
576
+ poolReviewDesc: 'Read-only review and independent verification',
577
+ poolSelectAll: 'Select all',
578
+ poolClear: 'Clear',
579
+ poolEmpty: 'No candidate models',
580
+ poolManualLabel: 'Manual order',
581
+ poolManualHelp:
582
+ 'When on, dispatch follows the listed order (head = first choice); when off, lanes are ordered by capability automatically.',
583
+ poolManualSelected: 'Selected · dispatch priority',
584
+ poolManualAddable: 'Add models',
585
+ poolMoveUp: 'Move up',
586
+ poolMoveDown: 'Move down',
587
+ poolRemoveOne: 'Remove',
588
+ poolEffortFollow: 'follow lane',
589
+ poolEffortLabel: 'effort',
590
+ poolEffortHelp:
591
+ "Pin this model's reasoning effort; it overrides the lane default when dispatching",
592
+ catalogLoading: 'Loading model catalog…',
593
+ catalogLoadFailed: 'The model catalog could not be loaded.',
594
+ catalogRetry: 'Retry',
595
+ catalogPartial: 'Some model providers could not be loaded; saved choices remain removable.',
596
+ catalogEmpty: 'No model provider currently advertises a model.',
597
+ catalogUnavailableGroup: 'Saved but currently unavailable',
598
+ catalogUnavailable: 'currently unavailable',
599
+ rankTitle: 'Capability ranking',
600
+ rankDescription:
601
+ 'Numbered strongest first; each entry may anchor an S/A/B/C tier. The list is pool selections ∪ the saved ranking; any edit persists the displayed order.',
602
+ rankEmpty: 'Select models in the pools above; the ranking list appears automatically.',
603
+ rankTier: 'Tier',
604
+ rankTierNone: '—',
605
+ rankMoveUp: 'Move up',
606
+ rankMoveDown: 'Move down',
607
+ rankRemove: 'Remove from ranking',
608
+ enforceLabel: 'Enforcement',
609
+ enforceOff: 'Off',
610
+ enforceAdvice: 'Advice',
611
+ enforceEnforce: 'Enforce',
612
+ enforceHelpOff: 'No pool guidance is injected into delegation.',
613
+ enforceHelpAdvice: 'Pool guidance is injected as advice; models may deviate.',
614
+ enforceHelpEnforce: 'Pool guidance is injected as a hard constraint; deviations are refused.',
615
+ wmSectionTitle: 'Context watermark',
616
+ wmSectionDescription:
617
+ 'Tiered behavior by token usage: advice, capped reads and wrap-up, automatic handover.',
618
+ wmSoftLabel: 'Soft watermark',
619
+ wmSoftHelp: 'Past this, a banner appears and delegating or tidying the context is advised.',
620
+ wmHardLabel: 'Hard watermark',
621
+ wmHardHelp: 'Past this, the read budget tightens and wrapping up is advised.',
622
+ wmForceLabel: 'Force watermark',
623
+ wmForceHelp: 'Past this, back up and hand over to compaction (can be automatic).',
624
+ wmReadBudgetLabel: 'Read budget',
625
+ wmReadBudgetHelp: 'Token budget per read; each round caps at twice this.',
626
+ wmNumberInvalid: 'Enter a whole number of {min} or more.',
627
+ wmOrderInvalid: 'The three tiers must strictly ascend: soft < hard < force.',
628
+ wmDenyModeLabel: 'Hard-tier behavior',
629
+ wmDenyModeCap: 'Cap and warn',
630
+ wmDenyModeDeny: 'Deny outright',
631
+ wmDenyModeHelpCap: 'Over-budget calls pass, but a warning is recorded.',
632
+ wmDenyModeHelpDeny: 'Over-budget calls are denied outright.',
633
+ wmAutoHandoverLabel: 'Automatic handover',
634
+ wmAutoHandoverHelp: 'At the force watermark, back up and hand over to compaction automatically.',
635
+ wmSubagentTitle: 'Subagents',
636
+ wmSubagentCapLabel: 'Subagent context cap',
637
+ wmSubagentCapHelp:
638
+ 'Lets subagents use the dedicated force threshold below; off = fully follow the three tiers above.',
639
+ wmSubagentForceLabel: 'Subagent force threshold',
640
+ wmSubagentForceHelp:
641
+ 'Overrides only the subagent force tier (soft/hard are shared with the main session); 0 follows the force watermark above.',
642
+ wmSubagentFollowForce: 'Follow the force tier (0)',
643
+ wmCommands:
644
+ 'Commands: /ctx-pause suspends watermark actions · /ctx-resume resumes them · /ctx-handover backs up and hands over now.',
645
+ },
646
+ };
647
+
648
+ // ------------------------------------------------------------------
649
+ // Session-header badge (Phase 0, unchanged)
650
+ // ------------------------------------------------------------------
651
+
652
+ function SwitchmanBadge({ locale, t }) {
653
+ // Subscription only: stable snapshot via getLocale() re-renders on switch.
654
+ React.useSyncExternalStore(
655
+ (fn) => locale.subscribe(fn),
656
+ () => locale.getLocale(),
657
+ );
658
+ const text = t('badge');
659
+ const tip = t('badgeTip');
660
+ return h(
661
+ 'span',
662
+ {
663
+ title: tip,
664
+ 'data-dsh-switchman': 'team-autonomy',
665
+ style: {
666
+ borderRadius: 'var(--dsw-radius-xs)',
667
+ // NOTE: --dsw-alias-fill-tsp-secondary (used by the shipped preset
668
+ // chip) is a dangling reference — no --dsw-alias-fill-* family exists
669
+ // in the theme alias tables, so it silently computes to transparent.
670
+ // Use the real hover-fill alias (defined for both light and dark).
671
+ background: 'var(--dsw-alias-interactive-bg-hover, transparent)',
672
+ height: '22px',
673
+ color: 'var(--dsw-alias-label-tertiary)',
674
+ whiteSpace: 'nowrap',
675
+ display: 'inline-flex',
676
+ alignItems: 'center',
677
+ gap: '4px',
678
+ padding: '0 8px',
679
+ fontSize: '12px',
680
+ lineHeight: '22px',
681
+ flex: 'none',
682
+ },
683
+ },
684
+ h('span', { 'aria-hidden': true, style: { flex: 'none' } }, '⚡'),
685
+ h('span', null, text),
686
+ );
687
+ }
688
+
689
+ // ------------------------------------------------------------------
690
+ // Settings page (Phase 1)
691
+ // ------------------------------------------------------------------
692
+
693
+ /** Shared inline-style fragments (theme aliases only, no new deps). */
694
+ const STYLE = {
695
+ page: { minWidth: 0, minHeight: 0 },
696
+ section: { minWidth: 0, padding: '16px 0' },
697
+ heading: {
698
+ color: 'var(--dsw-alias-label-primary)',
699
+ margin: 0,
700
+ fontSize: '13px',
701
+ fontWeight: 600,
702
+ lineHeight: 1.5,
703
+ },
704
+ description: {
705
+ color: 'var(--dsw-alias-label-secondary)',
706
+ margin: '4px 0 0',
707
+ fontSize: '12px',
708
+ lineHeight: 1.5,
709
+ },
710
+ rows: { display: 'grid', gap: '16px', margin: '16px 0 0' },
711
+ row: {
712
+ display: 'grid',
713
+ gridTemplateColumns: 'minmax(160px, 240px) minmax(0, 1fr)',
714
+ gap: '12px',
715
+ alignItems: 'start',
716
+ },
717
+ rowLabel: {
718
+ color: 'var(--dsw-alias-label-primary)',
719
+ fontSize: '13px',
720
+ lineHeight: 1.5,
721
+ },
722
+ rowHelp: {
723
+ color: 'var(--dsw-alias-label-tertiary)',
724
+ margin: '2px 0 0',
725
+ fontSize: '12px',
726
+ lineHeight: 1.5,
727
+ },
728
+ control: {
729
+ font: 'inherit',
730
+ fontSize: '13px',
731
+ lineHeight: 1.5,
732
+ color: 'var(--dsw-alias-label-primary)',
733
+ background: 'transparent',
734
+ border: '.5px solid var(--dsw-alias-border-l2)',
735
+ borderRadius: 'var(--dsw-radius-xs)',
736
+ padding: '4px 8px',
737
+ minWidth: '0',
738
+ width: 'min(280px, 100%)',
739
+ },
740
+ currentValue: {
741
+ color: 'var(--dsw-alias-label-tertiary)',
742
+ margin: '6px 0 0',
743
+ fontSize: '12px',
744
+ lineHeight: 1.5,
745
+ },
746
+ invalid: {
747
+ color: 'var(--dsw-alias-state-error-primary)',
748
+ margin: '6px 0 0',
749
+ fontSize: '12px',
750
+ lineHeight: 1.5,
751
+ },
752
+ footer: {
753
+ display: 'flex',
754
+ alignItems: 'center',
755
+ justifyContent: 'space-between',
756
+ gap: '12px',
757
+ flexWrap: 'wrap',
758
+ borderTop: '.5px solid var(--dsw-alias-border-l1)',
759
+ marginTop: '20px',
760
+ paddingTop: '12px',
761
+ },
762
+ message: {
763
+ color: 'var(--dsw-alias-label-secondary)',
764
+ margin: 0,
765
+ fontSize: '12px',
766
+ lineHeight: 1.5,
767
+ },
768
+ messageError: {
769
+ color: 'var(--dsw-alias-state-error-primary)',
770
+ margin: 0,
771
+ fontSize: '12px',
772
+ lineHeight: 1.5,
773
+ },
774
+ actions: { display: 'flex', gap: '8px', flex: 'none' },
775
+ buttonBase: {
776
+ font: 'inherit',
777
+ fontSize: '13px',
778
+ lineHeight: 1.5,
779
+ padding: '4px 14px',
780
+ borderRadius: 'var(--dsw-radius-xs)',
781
+ border: '1px solid var(--dsw-alias-border-l2)',
782
+ background: 'transparent',
783
+ cursor: 'pointer',
784
+ },
785
+ sectionDivider: {
786
+ borderTop: '.5px solid var(--dsw-alias-border-l1)',
787
+ },
788
+ progress: {
789
+ color: 'var(--dsw-alias-label-secondary)',
790
+ margin: '8px 0 0',
791
+ fontSize: '12px',
792
+ lineHeight: 1.5,
793
+ },
794
+ poolGrid: {
795
+ display: 'grid',
796
+ gridTemplateColumns: 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))',
797
+ gap: '12px',
798
+ margin: '16px 0 0',
799
+ },
800
+ poolCard: {
801
+ border: '.5px solid var(--dsw-alias-border-l2)',
802
+ borderRadius: 'var(--dsw-radius-md)',
803
+ padding: '12px',
804
+ minWidth: 0,
805
+ display: 'grid',
806
+ gap: '8px',
807
+ alignContent: 'start',
808
+ },
809
+ poolCardTitle: {
810
+ color: 'var(--dsw-alias-label-primary)',
811
+ fontSize: '13px',
812
+ fontWeight: 600,
813
+ lineHeight: 1.5,
814
+ },
815
+ poolCardDesc: {
816
+ color: 'var(--dsw-alias-label-tertiary)',
817
+ margin: 0,
818
+ fontSize: '12px',
819
+ lineHeight: 1.5,
820
+ },
821
+ poolActions: {
822
+ display: 'flex',
823
+ gap: '12px',
824
+ flex: 'none',
825
+ },
826
+ linkButton: {
827
+ font: 'inherit',
828
+ fontSize: '12px',
829
+ lineHeight: 1.5,
830
+ padding: '0',
831
+ border: 'none',
832
+ background: 'transparent',
833
+ color: 'var(--dsw-alias-label-secondary)',
834
+ cursor: 'pointer',
835
+ },
836
+ poolList: {
837
+ maxHeight: '190px',
838
+ overflowY: 'auto',
839
+ display: 'grid',
840
+ gap: '2px',
841
+ borderTop: '.5px solid var(--dsw-alias-border-l1)',
842
+ paddingTop: '6px',
843
+ },
844
+ groupHeader: {
845
+ color: 'var(--dsw-alias-label-tertiary)',
846
+ margin: '6px 0 2px',
847
+ fontSize: '11px',
848
+ fontWeight: 600,
849
+ lineHeight: 1.5,
850
+ },
851
+ checkRow: {
852
+ display: 'flex',
853
+ alignItems: 'center',
854
+ gap: '6px',
855
+ minWidth: 0,
856
+ fontSize: '12.5px',
857
+ lineHeight: 1.6,
858
+ color: 'var(--dsw-alias-label-primary)',
859
+ cursor: 'pointer',
860
+ },
861
+ checkText: {
862
+ overflow: 'hidden',
863
+ textOverflow: 'ellipsis',
864
+ whiteSpace: 'nowrap',
865
+ },
866
+ unavailableMark: {
867
+ color: 'var(--dsw-alias-label-tertiary)',
868
+ flex: 'none',
869
+ },
870
+ rankList: { display: 'grid', gap: '2px', marginTop: '8px' },
871
+ rankRow: {
872
+ display: 'grid',
873
+ gridTemplateColumns: '24px minmax(0, 1fr) auto auto',
874
+ gap: '8px',
875
+ alignItems: 'center',
876
+ padding: '2px 0',
877
+ },
878
+ rankIndex: {
879
+ color: 'var(--dsw-alias-label-tertiary)',
880
+ fontSize: '12px',
881
+ fontVariantNumeric: 'tabular-nums',
882
+ textAlign: 'right',
883
+ },
884
+ rankLabel: {
885
+ color: 'var(--dsw-alias-label-primary)',
886
+ fontSize: '12.5px',
887
+ lineHeight: 1.6,
888
+ overflow: 'hidden',
889
+ textOverflow: 'ellipsis',
890
+ whiteSpace: 'nowrap',
891
+ },
892
+ rankActions: { display: 'flex', gap: '4px', flex: 'none' },
893
+ effortSelect: {
894
+ font: 'inherit',
895
+ fontSize: '12px',
896
+ lineHeight: 1.5,
897
+ padding: '1px 2px',
898
+ borderRadius: 'var(--dsw-radius-xs)',
899
+ border: '.5px solid var(--dsw-alias-border-l2)',
900
+ background: 'transparent',
901
+ color: 'var(--dsw-alias-label-secondary)',
902
+ marginLeft: '8px',
903
+ flex: 'none',
904
+ },
905
+ iconButton: {
906
+ font: 'inherit',
907
+ fontSize: '12px',
908
+ lineHeight: 1.5,
909
+ padding: '1px 7px',
910
+ borderRadius: 'var(--dsw-radius-xs)',
911
+ border: '.5px solid var(--dsw-alias-border-l2)',
912
+ background: 'transparent',
913
+ color: 'var(--dsw-alias-label-secondary)',
914
+ cursor: 'pointer',
915
+ },
916
+ subheading: {
917
+ color: 'var(--dsw-alias-label-primary)',
918
+ margin: '20px 0 0',
919
+ fontSize: '13px',
920
+ fontWeight: 600,
921
+ lineHeight: 1.5,
922
+ },
923
+ checkbox: {
924
+ width: '16px',
925
+ height: '16px',
926
+ margin: '5px 0',
927
+ accentColor: 'var(--dsw-alias-brand-primary)',
928
+ },
929
+ };
930
+
931
+ /** One settings row: label + help on the left, control + status lines
932
+ * (current value, suggestion, validation) on the right. */
933
+ function SettingsRow({ id, label, help, control, lines }) {
934
+ return h(
935
+ 'div',
936
+ { style: STYLE.row },
937
+ h(
938
+ 'div',
939
+ null,
940
+ h('label', { htmlFor: id, style: STYLE.rowLabel }, label),
941
+ help === undefined ? null : h('p', { style: STYLE.rowHelp }, help),
942
+ ),
943
+ h(
944
+ 'div',
945
+ { style: { minWidth: 0 } },
946
+ control,
947
+ ...lines.map((line, index) =>
948
+ h(
949
+ 'p',
950
+ {
951
+ key: index,
952
+ role: 'status',
953
+ style: line.invalid ? STYLE.invalid : STYLE.currentValue,
954
+ },
955
+ line.text,
956
+ ),
957
+ ),
958
+ ),
959
+ );
960
+ }
961
+
962
+ /** One <option> per candidate, rendered "native (tag)". */
963
+ function languageOptions(t, { withUnset, withCustom }) {
964
+ const options = [];
965
+ if (withUnset)
966
+ options.push(
967
+ h(
968
+ 'option',
969
+ { key: '', value: '' },
970
+ withUnset === 'follow'
971
+ ? t('followConversation')
972
+ : t('notSetAsk'),
973
+ ),
974
+ );
975
+ for (const language of LANGUAGES)
976
+ options.push(
977
+ h(
978
+ 'option',
979
+ { key: language.value, value: language.value },
980
+ `${language.label} (${language.value})`,
981
+ ),
982
+ );
983
+ if (withCustom)
984
+ options.push(
985
+ h('option', { key: CUSTOM, value: CUSTOM }, t('customLanguage')),
986
+ );
987
+ return options;
988
+ }
989
+
990
+ /** One dispatch-pool card: title + positioning, select-all/clear, and a
991
+ * provider-grouped checkbox list of catalog candidates plus this pool's
992
+ * saved-but-unavailable routes. With `manual` on, the body switches to
993
+ * a stored-order priority list (move up/down/remove) plus an
994
+ * add-only checkbox list. */
995
+ function PoolCard({
996
+ t,
997
+ title,
998
+ description,
999
+ rows,
1000
+ extras,
1001
+ routes,
1002
+ efforts,
1003
+ manual,
1004
+ editable,
1005
+ onToggle,
1006
+ onToggleManual,
1007
+ onSetEffort,
1008
+ onMove,
1009
+ onRemove,
1010
+ onSelectAll,
1011
+ onClear,
1012
+ }) {
1013
+ const hasCandidates = rows.length > 0 || extras.length > 0;
1014
+ /** 显示名查找:覆盖目录行与已存离架行。 */
1015
+ const nameByKey = new Map(
1016
+ [...rows, ...extras]
1017
+ .filter((row) => row.header === undefined)
1018
+ .map((row) => [
1019
+ routeKey(row),
1020
+ `${row.providerName} · ${row.modelName}`,
1021
+ ]),
1022
+ );
1023
+ /** effort 查找:该池已配置条目的 key → effort。 */
1024
+ const effortByKey = new Map(
1025
+ efforts.map((entry) => [routeKey(entry), entry.effort]),
1026
+ );
1027
+ /** effort 元数据查找:routeKey → wire reasoning.efforts(无则缺省)。 */
1028
+ const effortsMetaByKey = new Map(
1029
+ [...rows, ...extras]
1030
+ .filter(
1031
+ (row) =>
1032
+ row.header === undefined && Array.isArray(row.reasoning?.efforts),
1033
+ )
1034
+ .map((row) => [routeKey(row), row.reasoning.efforts]),
1035
+ );
1036
+ /** 单个已选模型的 effort 下拉选项:优先该模型 wire 里的真实
1037
+ * efforts;拿不到时退回 EFFORTS 常量;已存旧值不在列表里时追加,
1038
+ * 避免受控 select 错位。 */
1039
+ const effortOptions = (route) => {
1040
+ const key = routeKey(route);
1041
+ const wireEfforts = effortsMetaByKey.get(key);
1042
+ const raw =
1043
+ wireEfforts !== undefined && wireEfforts.length > 0
1044
+ ? wireEfforts
1045
+ : EFFORTS.map((effort) => ({ id: effort }));
1046
+ const seen = new Set(raw.map((entry) => entry.id));
1047
+ const current = effortByKey.get(key);
1048
+ const pending =
1049
+ typeof current === 'string' && current !== '' && !seen.has(current)
1050
+ ? [{ id: current }]
1051
+ : [];
1052
+ return [
1053
+ { id: '', name: t('poolEffortFollow') },
1054
+ ...raw.map((entry) => ({
1055
+ id: entry.id,
1056
+ name: entry.name ?? entry.id,
1057
+ })),
1058
+ ...pending,
1059
+ ];
1060
+ };
1061
+ /** 单个已选模型的 effort 下拉('' = 跟随泳道默认);阻止冒泡避免
1062
+ * 触发外层 label 的 checkbox。 */
1063
+ const effortSelect = (route) =>
1064
+ h(
1065
+ 'select',
1066
+ {
1067
+ style: STYLE.effortSelect,
1068
+ title: t('poolEffortHelp'),
1069
+ 'aria-label': `${t('poolEffortLabel')} · ${route.provider}/${route.model}`,
1070
+ disabled: !editable,
1071
+ value: effortByKey.get(routeKey(route)) ?? '',
1072
+ onClick: (e) => e.stopPropagation(),
1073
+ onChange: (e) => {
1074
+ onSetEffort(route, e.target.value);
1075
+ },
1076
+ },
1077
+ ...effortOptions(route).map((option) =>
1078
+ h('option', { value: option.id }, option.name),
1079
+ ),
1080
+ );
1081
+ /** 手动态的「可添加」列表:仅未勾选的目录行,空分组头剔除。 */
1082
+ const addableRows = [];
1083
+ if (manual) {
1084
+ let pendingHeader = null;
1085
+ for (const row of rows) {
1086
+ if (row.header !== undefined) {
1087
+ pendingHeader = row;
1088
+ continue;
1089
+ }
1090
+ if (row.selected) continue;
1091
+ if (pendingHeader !== null) {
1092
+ addableRows.push(pendingHeader);
1093
+ pendingHeader = null;
1094
+ }
1095
+ addableRows.push(row);
1096
+ }
1097
+ }
1098
+ /** 标题行右侧的手动序开关。 */
1099
+ const manualToggle = h(
1100
+ 'label',
1101
+ { style: STYLE.checkRow, title: t('poolManualHelp') },
1102
+ h('input', {
1103
+ type: 'checkbox',
1104
+ checked: manual,
1105
+ disabled: !editable,
1106
+ onChange: onToggleManual,
1107
+ }),
1108
+ h('span', { style: STYLE.checkText }, t('poolManualLabel')),
1109
+ );
1110
+ if (manual)
1111
+ return h(
1112
+ 'div',
1113
+ { style: STYLE.poolCard },
1114
+ h('div', { style: STYLE.poolCardTitle }, title, manualToggle),
1115
+ h('p', { style: STYLE.poolCardDesc }, description),
1116
+ routes.length > 0
1117
+ ? h(
1118
+ 'div',
1119
+ { style: STYLE.poolList },
1120
+ h('p', { style: STYLE.groupHeader }, t('poolManualSelected')),
1121
+ h(
1122
+ 'div',
1123
+ { style: STYLE.rankList },
1124
+ ...routes.map((route, index) =>
1125
+ h(
1126
+ 'div',
1127
+ { key: routeKey(route), style: STYLE.rankRow },
1128
+ h('span', { style: STYLE.rankIndex }, String(index + 1)),
1129
+ h(
1130
+ 'span',
1131
+ {
1132
+ style: STYLE.rankLabel,
1133
+ title: `${route.provider}/${route.model}`,
1134
+ },
1135
+ nameByKey.get(routeKey(route)) ??
1136
+ `${route.provider} · ${route.model}`,
1137
+ ),
1138
+ effortSelect(route),
1139
+ h(
1140
+ 'div',
1141
+ { style: STYLE.rankActions },
1142
+ h(
1143
+ 'button',
1144
+ {
1145
+ type: 'button',
1146
+ onClick: () => {
1147
+ onMove(index, -1);
1148
+ },
1149
+ disabled: !editable || index === 0,
1150
+ title: t('poolMoveUp'),
1151
+ 'aria-label': t('poolMoveUp'),
1152
+ style: STYLE.iconButton,
1153
+ },
1154
+ '↑',
1155
+ ),
1156
+ h(
1157
+ 'button',
1158
+ {
1159
+ type: 'button',
1160
+ onClick: () => {
1161
+ onMove(index, 1);
1162
+ },
1163
+ disabled: !editable || index === routes.length - 1,
1164
+ title: t('poolMoveDown'),
1165
+ 'aria-label': t('poolMoveDown'),
1166
+ style: STYLE.iconButton,
1167
+ },
1168
+ '↓',
1169
+ ),
1170
+ h(
1171
+ 'button',
1172
+ {
1173
+ type: 'button',
1174
+ onClick: () => {
1175
+ onRemove(index);
1176
+ },
1177
+ disabled: !editable,
1178
+ title: t('poolRemoveOne'),
1179
+ 'aria-label': t('poolRemoveOne'),
1180
+ style: STYLE.iconButton,
1181
+ },
1182
+ '✕',
1183
+ ),
1184
+ ),
1185
+ ),
1186
+ ),
1187
+ ),
1188
+ )
1189
+ : h('p', { style: STYLE.poolCardDesc }, t('poolEmpty')),
1190
+ addableRows.length > 0
1191
+ ? h(
1192
+ 'div',
1193
+ { style: STYLE.poolList },
1194
+ h('p', { style: STYLE.groupHeader }, t('poolManualAddable')),
1195
+ ...addableRows.map((row, index) =>
1196
+ row.header !== undefined
1197
+ ? h(
1198
+ 'p',
1199
+ { key: `h${index}`, style: STYLE.groupHeader },
1200
+ row.header,
1201
+ )
1202
+ : h(
1203
+ 'label',
1204
+ {
1205
+ key: routeKey(row),
1206
+ style: STYLE.checkRow,
1207
+ title: `${row.provider}/${row.model}`,
1208
+ },
1209
+ h('input', {
1210
+ type: 'checkbox',
1211
+ checked: false,
1212
+ disabled: !editable,
1213
+ onChange: () => {
1214
+ onToggle(row);
1215
+ },
1216
+ }),
1217
+ h(
1218
+ 'span',
1219
+ { style: STYLE.checkText },
1220
+ `${row.providerName} · ${row.modelName}`,
1221
+ ),
1222
+ ),
1223
+ ),
1224
+ )
1225
+ : null,
1226
+ h(
1227
+ 'div',
1228
+ { style: STYLE.poolActions },
1229
+ h(
1230
+ 'button',
1231
+ {
1232
+ type: 'button',
1233
+ onClick: onClear,
1234
+ disabled: !editable,
1235
+ style: STYLE.linkButton,
1236
+ },
1237
+ t('poolClear'),
1238
+ ),
1239
+ ),
1240
+ );
1241
+ return h(
1242
+ 'div',
1243
+ { style: STYLE.poolCard },
1244
+ h('div', { style: STYLE.poolCardTitle }, title, manualToggle),
1245
+ h('p', { style: STYLE.poolCardDesc }, description),
1246
+ h(
1247
+ 'div',
1248
+ { style: STYLE.poolActions },
1249
+ h(
1250
+ 'button',
1251
+ {
1252
+ type: 'button',
1253
+ onClick: onSelectAll,
1254
+ disabled: !editable,
1255
+ style: STYLE.linkButton,
1256
+ },
1257
+ t('poolSelectAll'),
1258
+ ),
1259
+ h(
1260
+ 'button',
1261
+ {
1262
+ type: 'button',
1263
+ onClick: onClear,
1264
+ disabled: !editable,
1265
+ style: STYLE.linkButton,
1266
+ },
1267
+ t('poolClear'),
1268
+ ),
1269
+ ),
1270
+ hasCandidates
1271
+ ? h(
1272
+ 'div',
1273
+ { style: STYLE.poolList },
1274
+ ...rows.map((row, index) =>
1275
+ row.header !== undefined
1276
+ ? h(
1277
+ 'p',
1278
+ { key: `h${index}`, style: STYLE.groupHeader },
1279
+ row.header,
1280
+ )
1281
+ : h(
1282
+ 'label',
1283
+ {
1284
+ key: routeKey(row),
1285
+ style: STYLE.checkRow,
1286
+ title: `${row.provider}/${row.model}`,
1287
+ },
1288
+ h('input', {
1289
+ type: 'checkbox',
1290
+ checked: row.selected,
1291
+ disabled: !editable,
1292
+ onChange: () => {
1293
+ onToggle(row);
1294
+ },
1295
+ }),
1296
+ h(
1297
+ 'span',
1298
+ { style: STYLE.checkText },
1299
+ `${row.providerName} · ${row.modelName}`,
1300
+ ),
1301
+ ...(row.selected ? [effortSelect(row)] : []),
1302
+ ),
1303
+ ),
1304
+ extras.length > 0
1305
+ ? h(
1306
+ 'p',
1307
+ { key: 'extras', style: STYLE.groupHeader },
1308
+ t('catalogUnavailableGroup'),
1309
+ )
1310
+ : null,
1311
+ ...extras.map((row) =>
1312
+ h(
1313
+ 'label',
1314
+ {
1315
+ key: routeKey(row),
1316
+ style: STYLE.checkRow,
1317
+ title: `${row.provider}/${row.model}`,
1318
+ },
1319
+ h('input', {
1320
+ type: 'checkbox',
1321
+ checked: true,
1322
+ disabled: !editable,
1323
+ onChange: () => {
1324
+ onToggle(row);
1325
+ },
1326
+ }),
1327
+ h(
1328
+ 'span',
1329
+ { style: STYLE.checkText },
1330
+ `${row.providerName} · ${row.modelName}`,
1331
+ ),
1332
+ effortSelect(row),
1333
+ h(
1334
+ 'span',
1335
+ { style: STYLE.unavailableMark },
1336
+ ` · ${t('catalogUnavailable')}`,
1337
+ ),
1338
+ ),
1339
+ ),
1340
+ )
1341
+ : h('p', { style: STYLE.poolCardDesc }, t('poolEmpty')),
1342
+ );
1343
+ }
1344
+
1345
+ /** The dsh-switchman settings page: language preferences, dispatch
1346
+ * pools, capability ranking, and enforcement mode — one staged draft
1347
+ * (revision-fenced) committed through one atomic mutate, following the
1348
+ * shipped settings pages' save/conflict model. Pool and ranking
1349
+ * candidates come from the live model catalog, refreshed whenever the
1350
+ * Host's adapter set changes. */
1351
+ function SwitchmanSettingsPage({ t, locale }) {
1352
+ // Re-render on UI-language switches (drives t() and the suggestion).
1353
+ React.useSyncExternalStore(
1354
+ (fn) => locale.subscribe(fn),
1355
+ () => locale.getLocale(),
1356
+ );
1357
+ // Hooks above the availability early-returns keep a stable order.
1358
+ const headingId = React.useId();
1359
+
1360
+ /** Live settings snapshot from the Host route: { status, writable,
1361
+ * values } — mirrors the configForms snapshot shape the page was
1362
+ * originally written against. */
1363
+ const [form, setForm] = React.useState({ status: 'loading', writable: false });
1364
+ /** draft: { values } | null — values hold all three fields. */
1365
+ const [draft, setDraft] = React.useState(null);
1366
+ /** Explicitly switched to the "Custom…" select entry. */
1367
+ const [customArmed, setCustomArmed] = React.useState(false);
1368
+ const [saving, setSaving] = React.useState(false);
1369
+ const [failed, setFailed] = React.useState(false);
1370
+ const [conflicted, setConflicted] = React.useState(false);
1371
+
1372
+ // Mirror the editable state for async save flows below (setState
1373
+ // alone is too late inside stale closures).
1374
+ const draftRef = React.useRef(null);
1375
+ const savingRef = React.useRef(false);
1376
+ const saveGeneration = React.useRef(0);
1377
+ /** Values this page loaded — the optimistic-concurrency baseline the
1378
+ * Host route fences writes against. */
1379
+ const loadedRef = React.useRef(null);
1380
+
1381
+ const assignDraft = (next) => {
1382
+ draftRef.current = next;
1383
+ setDraft(next);
1384
+ };
1385
+
1386
+ // Load the settings snapshot from the Host route on mount.
1387
+ React.useEffect(() => {
1388
+ let alive = true;
1389
+ void (async () => {
1390
+ const response = await api.get('config');
1391
+ if (!alive) return;
1392
+ if (response.ok && response.value?.values !== undefined) {
1393
+ loadedRef.current = response.value.values;
1394
+ setForm({ status: 'ready', writable: true, values: response.value.values });
1395
+ } else setForm({ status: 'error', writable: false });
1396
+ })();
1397
+ return () => {
1398
+ alive = false;
1399
+ };
1400
+ }, []);
1401
+
1402
+ // Suppress late save settlements after unmount.
1403
+ React.useEffect(() => () => {
1404
+ saveGeneration.current += 1;
1405
+ }, []);
1406
+
1407
+ // Model catalog (Phase 2): loaded once on mount from the Host
1408
+ // route (the restricted runtime has no remote service to subscribe
1409
+ // to adapter updates); generation-guarded so a stale answer cannot
1410
+ // overwrite a newer one.
1411
+ const [catalog, setCatalog] = React.useState({
1412
+ status: 'idle',
1413
+ groups: [],
1414
+ failures: 0,
1415
+ });
1416
+ const catalogGeneration = React.useRef(0);
1417
+ const loadCatalog = React.useCallback(async () => {
1418
+ const generation = ++catalogGeneration.current;
1419
+ setCatalog((previous) => ({ ...previous, status: 'loading' }));
1420
+ const response = await api.get('models');
1421
+ if (generation !== catalogGeneration.current) return;
1422
+ if (response.ok && Array.isArray(response.value?.groups))
1423
+ setCatalog({
1424
+ status: 'ready',
1425
+ groups: response.value.groups,
1426
+ failures: response.value.failures?.length ?? 0,
1427
+ });
1428
+ else setCatalog((previous) => ({ ...previous, status: 'error' }));
1429
+ }, []);
1430
+ React.useEffect(() => {
1431
+ loadCatalog();
1432
+ }, [loadCatalog]);
1433
+ const catalogIndex = indexCatalog(catalog.groups);
1434
+
1435
+ // Numeric watermark editing: one raw-text overlay per field so
1436
+ // partial input ('', '5', leading zeros) is editable without
1437
+ // corrupting the draft; parseable integers commit immediately,
1438
+ // blanks/invalid text stay local but block saving. Cleared on blur
1439
+ // (display falls back to the field's draft value) and whenever the
1440
+ // draft itself is cleared.
1441
+ const [numText, setNumText] = React.useState({});
1442
+ const clearNumText = () => {
1443
+ setNumText({});
1444
+ };
1445
+
1446
+ const current = readCurrent(form);
1447
+ const values = draft === null ? current : draft.values;
1448
+ const customActive =
1449
+ customArmed ||
1450
+ (values.langConversation !== '' &&
1451
+ !isCandidate(values.langConversation));
1452
+ /** Per-field numeric validity: raw text (when present) must be a
1453
+ * whole number at or above the schema minimum. */
1454
+ const numberInvalid = Object.fromEntries(
1455
+ WM_NUMBER_FIELDS.map(({ field, min }) => {
1456
+ const raw = numText[field];
1457
+ return [
1458
+ field,
1459
+ raw !== undefined &&
1460
+ (!/^\d+$/.test(raw.trim()) || Number(raw.trim()) < min),
1461
+ ];
1462
+ }),
1463
+ );
1464
+ const anyNumberInvalid = Object.values(numberInvalid).some(Boolean);
1465
+ /** Ported hard rule (opencode config.ts): strict soft < hard < force. */
1466
+ const orderInvalid =
1467
+ !(values.wmSoftTokens < values.wmHardTokens) ||
1468
+ !(values.wmHardTokens < values.wmForceTokens);
1469
+ const invalid =
1470
+ (customActive && values.langConversation.trim() === '') ||
1471
+ anyNumberInvalid ||
1472
+ orderInvalid;
1473
+ const dirty = !sameValues(current, values);
1474
+ const available = form.status === 'ready';
1475
+ const editable = available && form.writable && !saving;
1476
+
1477
+ /** Begin or extend the draft with one field write. */
1478
+ function edit(field, value) {
1479
+ if (!editable) return;
1480
+ let next = draftRef.current;
1481
+ if (next === null)
1482
+ next = { values: current };
1483
+ assignDraft({
1484
+ ...next,
1485
+ values: { ...next.values, [field]: value },
1486
+ });
1487
+ setFailed(false);
1488
+ }
1489
+
1490
+ /** Number-input onChange: stage the raw text, commit only whole
1491
+ * numbers (the draft always holds parseable values). */
1492
+ function editNumber(field, raw) {
1493
+ setNumText((previous) => ({ ...previous, [field]: raw }));
1494
+ const trimmed = raw.trim();
1495
+ if (/^\d+$/.test(trimmed)) edit(field, Number(trimmed));
1496
+ }
1497
+
1498
+ /** Number-input onBlur: drop the overlay so the display falls back
1499
+ * to the (last committed or accepted) numeric value. */
1500
+ const blurNumber = (field) => () => {
1501
+ setNumText((previous) => {
1502
+ if (previous[field] === undefined) return previous;
1503
+ const next = { ...previous };
1504
+ delete next[field];
1505
+ return next;
1506
+ });
1507
+ };
1508
+
1509
+ /** Conversation select: a candidate/'' write disarms custom mode. */
1510
+ function selectConversation(value) {
1511
+ if (value === CUSTOM) {
1512
+ setCustomArmed(true);
1513
+ edit('langConversation', '');
1514
+ } else {
1515
+ setCustomArmed(false);
1516
+ edit('langConversation', value);
1517
+ }
1518
+ }
1519
+
1520
+ /** Toggle one route in one dispatch pool (checkbox rows). */
1521
+ function togglePool(field, meta) {
1522
+ const pool = values[field];
1523
+ const key = routeKey(meta);
1524
+ const existed = pool.some((route) => routeKey(route) === key);
1525
+ edit(
1526
+ field,
1527
+ existed
1528
+ ? pool.filter((route) => routeKey(route) !== key)
1529
+ : [...pool, { provider: meta.provider, model: meta.model }],
1530
+ );
1531
+ // 取消勾选时同步移除该路由的手动 effort 条目
1532
+ if (existed) {
1533
+ const { effortsField } = POOL_FIELDS.find(
1534
+ (spec) => spec.field === field,
1535
+ );
1536
+ edit(
1537
+ effortsField,
1538
+ values[effortsField].filter((entry) => routeKey(entry) !== key),
1539
+ );
1540
+ }
1541
+ }
1542
+
1543
+ /** Select every listed candidate (catalog + saved extras) in one
1544
+ * pool, or clear it. */
1545
+ function setAllPool(field, select) {
1546
+ if (!select) {
1547
+ edit(field, []);
1548
+ // 清空池时同步清空该池的手动 effort 条目
1549
+ const { effortsField } = POOL_FIELDS.find(
1550
+ (spec) => spec.field === field,
1551
+ );
1552
+ edit(effortsField, []);
1553
+ return;
1554
+ }
1555
+ const extras = values[field]
1556
+ .filter((route) => !catalogIndex.has(routeKey(route)))
1557
+ .map((route) => ({ provider: route.provider, model: route.model }));
1558
+ const clean = (meta) => ({ provider: meta.provider, model: meta.model });
1559
+ edit(field, [...[...catalogIndex.values()].map(clean), ...extras]);
1560
+ }
1561
+
1562
+ /** 手动序:把 index 处的路由与相邻项交换(边界守卫)。 */
1563
+ function movePoolRoute(field, index, delta) {
1564
+ const pool = values[field];
1565
+ const target = index + delta;
1566
+ if (target < 0 || target >= pool.length) return;
1567
+ const next = [...pool];
1568
+ [next[index], next[target]] = [next[target], next[index]];
1569
+ edit(field, next.map((route) => ({
1570
+ provider: route.provider,
1571
+ model: route.model,
1572
+ })));
1573
+ }
1574
+
1575
+ /** 手动序:移除 index 处的路由。 */
1576
+ function removePoolRoute(field, index) {
1577
+ const next = [...values[field]];
1578
+ const [removed] = next.splice(index, 1);
1579
+ edit(field, next.map((route) => ({
1580
+ provider: route.provider,
1581
+ model: route.model,
1582
+ })));
1583
+ // 同步移除该路由的手动 effort 条目
1584
+ const { effortsField } = POOL_FIELDS.find(
1585
+ (spec) => spec.field === field,
1586
+ );
1587
+ edit(
1588
+ effortsField,
1589
+ values[effortsField].filter(
1590
+ (entry) => routeKey(entry) !== routeKey(removed),
1591
+ ),
1592
+ );
1593
+ }
1594
+
1595
+ /** 设置/清除某池某路由的手动 effort 条目(effort '' = 移除条目)。 */
1596
+ function setPoolEffort(effortsField, meta, effort) {
1597
+ const key = routeKey(meta);
1598
+ const rest = values[effortsField].filter(
1599
+ (entry) => routeKey(entry) !== key,
1600
+ );
1601
+ edit(
1602
+ effortsField,
1603
+ effort === ''
1604
+ ? rest
1605
+ : [
1606
+ ...rest,
1607
+ {
1608
+ provider: meta.provider,
1609
+ model: meta.model,
1610
+ effort,
1611
+ },
1612
+ ],
1613
+ );
1614
+ }
1615
+
1616
+ /** Apply one edit to the rank display list and persist the whole
1617
+ * displayed order as modelRank (a WYSIWYG edit materializes the
1618
+ * unranked tail's catalog order too — same ordering either way). */
1619
+ function editRank(mutate) {
1620
+ const display = rankDisplay(values, catalogIndex);
1621
+ mutate(display);
1622
+ edit('modelRank', rankValue(display));
1623
+ }
1624
+
1625
+ /** Move the entry at `index` by `delta` rows (bounds-guarded). */
1626
+ function moveRank(index, delta) {
1627
+ editRank((display) => {
1628
+ const target = index + delta;
1629
+ if (target < 0 || target >= display.length) return;
1630
+ [display[index], display[target]] = [display[target], display[index]];
1631
+ });
1632
+ }
1633
+
1634
+ /** Anchor (or clear, '') the tier of the entry at `index`. */
1635
+ function setRankTier(index, tier) {
1636
+ editRank((display) => {
1637
+ display[index] =
1638
+ tier === ''
1639
+ ? { provider: display[index].provider, model: display[index].model }
1640
+ : { ...display[index], tier };
1641
+ });
1642
+ }
1643
+
1644
+ /** Drop the entry at `index` from the ranking (a pool-selected
1645
+ * model returns to the unranked tail). */
1646
+ function removeRank(index) {
1647
+ editRank((display) => {
1648
+ display.splice(index, 1);
1649
+ });
1650
+ }
1651
+
1652
+ async function save() {
1653
+ if (form.status !== 'ready' || !form.writable || savingRef.current)
1654
+ return;
1655
+ const desired = {
1656
+ langConversation: values.langConversation.trim(),
1657
+ langComments: values.langComments,
1658
+ langDocs: values.langDocs,
1659
+ ...Object.fromEntries(
1660
+ POOL_FIELDS.map(({ field }) => [
1661
+ field,
1662
+ values[field].map((route) => ({
1663
+ provider: route.provider,
1664
+ model: route.model,
1665
+ })),
1666
+ ]),
1667
+ ),
1668
+ ...Object.fromEntries(
1669
+ POOL_FIELDS.map(({ manualField }) => [
1670
+ manualField,
1671
+ values[manualField],
1672
+ ]),
1673
+ ),
1674
+ ...Object.fromEntries(
1675
+ POOL_FIELDS.map(({ effortsField }) => [
1676
+ effortsField,
1677
+ values[effortsField].map((entry) => ({
1678
+ provider: entry.provider,
1679
+ model: entry.model,
1680
+ effort: entry.effort,
1681
+ })),
1682
+ ]),
1683
+ ),
1684
+ modelRank: rankValue(values.modelRank),
1685
+ dispatchEnforce: values.dispatchEnforce,
1686
+ ...Object.fromEntries(
1687
+ WM_NUMBER_FIELDS.map(({ field }) => [field, values[field]]),
1688
+ ),
1689
+ wmDenyMode: values.wmDenyMode,
1690
+ wmAutoHandover: values.wmAutoHandover,
1691
+ wmSubagentCap: values.wmSubagentCap,
1692
+ };
1693
+ if (sameValues(current, desired)) {
1694
+ // Whitespace-only differences: nothing to write, close the draft.
1695
+ if (draftRef.current !== null) {
1696
+ assignDraft(null);
1697
+ setCustomArmed(false);
1698
+ clearNumText();
1699
+ }
1700
+ return;
1701
+ }
1702
+ const generation = ++saveGeneration.current;
1703
+ savingRef.current = true;
1704
+ setSaving(true);
1705
+ setFailed(false);
1706
+ setConflicted(false);
1707
+ let response;
1708
+ try {
1709
+ response = await api.post('config', {
1710
+ expected: loadedRef.current,
1711
+ values: desired,
1712
+ });
1713
+ } catch {
1714
+ response = { ok: false, status: 0 };
1715
+ }
1716
+ if (generation !== saveGeneration.current) return;
1717
+ savingRef.current = false;
1718
+ setSaving(false);
1719
+ if (response.ok) {
1720
+ loadedRef.current = response.value.values;
1721
+ setForm({
1722
+ status: 'ready',
1723
+ writable: true,
1724
+ values: response.value.values,
1725
+ });
1726
+ assignDraft(null);
1727
+ setCustomArmed(false);
1728
+ clearNumText();
1729
+ return;
1730
+ }
1731
+ if (response.status === 409) {
1732
+ // Settings changed outside this page (e.g. the language
1733
+ // capture): adopt the fresh values, keep the draft, flag.
1734
+ setConflicted(true);
1735
+ if (response.value?.values !== undefined) {
1736
+ loadedRef.current = response.value.values;
1737
+ setForm({
1738
+ status: 'ready',
1739
+ writable: true,
1740
+ values: response.value.values,
1741
+ });
1742
+ }
1743
+ return;
1744
+ }
1745
+ setFailed(true);
1746
+ }
1747
+
1748
+ function discard() {
1749
+ if (savingRef.current) return;
1750
+ assignDraft(null);
1751
+ setCustomArmed(false);
1752
+ clearNumText();
1753
+ setFailed(false);
1754
+ setConflicted(false);
1755
+ }
1756
+
1757
+ if (form.status === 'loading')
1758
+ return h(
1759
+ 'p',
1760
+ { style: STYLE.message, role: 'status' },
1761
+ t('formLoading'),
1762
+ );
1763
+ if (!available)
1764
+ return h(
1765
+ 'p',
1766
+ { style: STYLE.message, role: 'status' },
1767
+ t('formUnavailable'),
1768
+ );
1769
+
1770
+ const conversationId = `${headingId}-conversation`;
1771
+ const commentsId = `${headingId}-comments`;
1772
+ const docsId = `${headingId}-docs`;
1773
+ const suggestion = suggestFromUiLocale(locale.getLocale().active);
1774
+
1775
+ /** Status lines under one row's control. */
1776
+ const linesFor = (field) => {
1777
+ const lines = [];
1778
+ if (field === 'langConversation' && invalid)
1779
+ lines.push({ text: t('customLanguageRequired'), invalid: true });
1780
+ lines.push(
1781
+ current[field] === ''
1782
+ ? {
1783
+ text:
1784
+ field === 'langConversation'
1785
+ ? t('currentValueUnset')
1786
+ : t('currentValue', {
1787
+ value: t('followConversation'),
1788
+ }),
1789
+ invalid: false,
1790
+ }
1791
+ : {
1792
+ text: t('currentValue', {
1793
+ value: formatLanguage(current[field]),
1794
+ }),
1795
+ invalid: false,
1796
+ },
1797
+ );
1798
+ if (field === 'langConversation' && current.langConversation === '')
1799
+ lines.push({
1800
+ text: t('suggestedValue', {
1801
+ value: formatLanguage(suggestion),
1802
+ }),
1803
+ invalid: false,
1804
+ });
1805
+ return lines;
1806
+ };
1807
+
1808
+ /** Shared select props (disabled while saving / read-only). */
1809
+ const selectProps = (id, value, onChange) => ({
1810
+ id,
1811
+ value,
1812
+ onChange: (event) => onChange(event.target.value),
1813
+ disabled: !editable,
1814
+ style: STYLE.control,
1815
+ });
1816
+
1817
+ const conversationControl = h(
1818
+ 'div',
1819
+ { style: { display: 'grid', gap: '6px', minWidth: 0 } },
1820
+ h(
1821
+ 'select',
1822
+ selectProps(
1823
+ conversationId,
1824
+ customActive
1825
+ ? CUSTOM
1826
+ : values.langConversation,
1827
+ selectConversation,
1828
+ ),
1829
+ ...languageOptions(t, { withUnset: 'ask', withCustom: true }),
1830
+ ),
1831
+ customActive
1832
+ ? h('input', {
1833
+ type: 'text',
1834
+ value: values.langConversation,
1835
+ onChange: (event) =>
1836
+ edit('langConversation', event.target.value),
1837
+ placeholder: t('customLanguagePlaceholder'),
1838
+ maxLength: LANG_MAX,
1839
+ disabled: !editable,
1840
+ 'aria-label': t('customLanguage'),
1841
+ style: STYLE.control,
1842
+ })
1843
+ : null,
1844
+ );
1845
+
1846
+ const saveDisabled =
1847
+ !form.writable || saving || conflicted || invalid || !dirty;
1848
+
1849
+ // ---- dispatch section derived state ----
1850
+ const dispatchId = `${headingId}-dispatch`;
1851
+ const rankId = `${headingId}-rank`;
1852
+ const enforceId = `${headingId}-enforce`;
1853
+ const poolsSet = POOL_FIELDS.filter(
1854
+ ({ field }) => values[field].length > 0,
1855
+ ).length;
1856
+ const enforceLabelKey = {
1857
+ off: 'enforceOff',
1858
+ advice: 'enforceAdvice',
1859
+ enforce: 'enforceEnforce',
1860
+ };
1861
+ const enforceHelpKey = {
1862
+ off: 'enforceHelpOff',
1863
+ advice: 'enforceHelpAdvice',
1864
+ enforce: 'enforceHelpEnforce',
1865
+ };
1866
+ const display = rankDisplay(values, catalogIndex);
1867
+
1868
+ // ---- watermark section derived state ----
1869
+ const wmId = `${headingId}-wm`;
1870
+
1871
+ /** One watermark number input: raw-text overlay over the draft
1872
+ * value (0 renders as the placeholder for the follow-force row),
1873
+ * red border while the raw text fails its minimum. */
1874
+ const wmNumberControl = (spec, placeholder) => {
1875
+ const { field } = spec;
1876
+ const shown =
1877
+ numText[field] ??
1878
+ (values[field] === 0 && placeholder !== undefined
1879
+ ? ''
1880
+ : String(values[field]));
1881
+ return h('input', {
1882
+ type: 'text',
1883
+ inputMode: 'numeric',
1884
+ id: `${wmId}-${field}`,
1885
+ value: shown,
1886
+ placeholder,
1887
+ onChange: (event) => {
1888
+ editNumber(field, event.target.value);
1889
+ },
1890
+ onBlur: blurNumber(field),
1891
+ disabled: !editable,
1892
+ 'aria-label': t(WM_ROW_KEYS[field].label),
1893
+ style: numberInvalid[field]
1894
+ ? {
1895
+ ...STYLE.control,
1896
+ borderColor: 'var(--dsw-alias-state-error-primary)',
1897
+ color: 'var(--dsw-alias-state-error-primary)',
1898
+ }
1899
+ : STYLE.control,
1900
+ });
1901
+ };
1902
+
1903
+ /** Status lines under one number row: minimum + order hints. */
1904
+ const wmNumberLines = (spec) => {
1905
+ const lines = [];
1906
+ if (numberInvalid[spec.field])
1907
+ lines.push({
1908
+ text: t('wmNumberInvalid', { min: spec.min }),
1909
+ invalid: true,
1910
+ });
1911
+ if (orderInvalid && WM_ORDER_FIELDS.includes(spec.field))
1912
+ lines.push({ text: t('wmOrderInvalid'), invalid: true });
1913
+ return lines;
1914
+ };
1915
+
1916
+ /** Catalog status line: loading / error + retry / partial / empty. */
1917
+ const catalogNotice =
1918
+ catalog.status === 'loading'
1919
+ ? h(
1920
+ 'p',
1921
+ { key: 'loading', role: 'status', style: STYLE.progress },
1922
+ t('catalogLoading'),
1923
+ )
1924
+ : catalog.status === 'error'
1925
+ ? h(
1926
+ 'div',
1927
+ {
1928
+ key: 'error',
1929
+ role: 'alert',
1930
+ style: { display: 'flex', gap: '12px', marginTop: '8px' },
1931
+ },
1932
+ h('span', { style: STYLE.messageError }, t('catalogLoadFailed')),
1933
+ h(
1934
+ 'button',
1935
+ {
1936
+ type: 'button',
1937
+ onClick: loadCatalog,
1938
+ disabled: saving,
1939
+ style: STYLE.linkButton,
1940
+ },
1941
+ t('catalogRetry'),
1942
+ ),
1943
+ )
1944
+ : catalog.status === 'ready' && catalog.failures > 0
1945
+ ? h(
1946
+ 'p',
1947
+ { key: 'partial', style: STYLE.progress },
1948
+ t('catalogPartial'),
1949
+ )
1950
+ : catalog.status === 'ready' && catalogIndex.size === 0
1951
+ ? h(
1952
+ 'p',
1953
+ { key: 'empty', style: STYLE.progress },
1954
+ t('catalogEmpty'),
1955
+ )
1956
+ : null;
1957
+
1958
+ /** One ranking row: index, label, tier select, move/remove buttons. */
1959
+ const rankRow = (entry, index) => {
1960
+ const key = routeKey(entry);
1961
+ const meta = catalogIndex.get(key);
1962
+ const up = t('rankMoveUp');
1963
+ const down = t('rankMoveDown');
1964
+ const remove = t('rankRemove');
1965
+ return h(
1966
+ 'div',
1967
+ { key, style: STYLE.rankRow },
1968
+ h('span', { style: STYLE.rankIndex }, String(index + 1)),
1969
+ h(
1970
+ 'span',
1971
+ {
1972
+ style: STYLE.rankLabel,
1973
+ title: `${entry.provider}/${entry.model}`,
1974
+ },
1975
+ `${meta?.providerName ?? entry.provider} · ${meta?.modelName ?? entry.model}`,
1976
+ meta === undefined
1977
+ ? h(
1978
+ 'span',
1979
+ { style: STYLE.unavailableMark },
1980
+ ` · ${t('catalogUnavailable')}`,
1981
+ )
1982
+ : null,
1983
+ ),
1984
+ h(
1985
+ 'select',
1986
+ {
1987
+ value: entry.tier ?? '',
1988
+ onChange: (event) => {
1989
+ setRankTier(index, event.target.value);
1990
+ },
1991
+ disabled: !editable,
1992
+ 'aria-label': t('rankTier'),
1993
+ style: { ...STYLE.control, width: 'auto', justifySelf: 'start' },
1994
+ },
1995
+ ['', ...TIERS].map((tier) =>
1996
+ h(
1997
+ 'option',
1998
+ { key: tier, value: tier },
1999
+ tier === '' ? t('rankTierNone') : tier,
2000
+ ),
2001
+ ),
2002
+ ),
2003
+ h(
2004
+ 'div',
2005
+ { style: STYLE.rankActions },
2006
+ h(
2007
+ 'button',
2008
+ {
2009
+ type: 'button',
2010
+ onClick: () => {
2011
+ moveRank(index, -1);
2012
+ },
2013
+ disabled: !editable || index === 0,
2014
+ title: up,
2015
+ 'aria-label': up,
2016
+ style: STYLE.iconButton,
2017
+ },
2018
+ '↑',
2019
+ ),
2020
+ h(
2021
+ 'button',
2022
+ {
2023
+ type: 'button',
2024
+ onClick: () => {
2025
+ moveRank(index, 1);
2026
+ },
2027
+ disabled: !editable || index === display.length - 1,
2028
+ title: down,
2029
+ 'aria-label': down,
2030
+ style: STYLE.iconButton,
2031
+ },
2032
+ '↓',
2033
+ ),
2034
+ h(
2035
+ 'button',
2036
+ {
2037
+ type: 'button',
2038
+ onClick: () => {
2039
+ removeRank(index);
2040
+ },
2041
+ disabled: !editable,
2042
+ title: remove,
2043
+ 'aria-label': remove,
2044
+ style: STYLE.iconButton,
2045
+ },
2046
+ '✕',
2047
+ ),
2048
+ ),
2049
+ );
2050
+ };
2051
+
2052
+ return h(
2053
+ 'div',
2054
+ { 'data-dsh-switchman': 'settings', style: STYLE.page },
2055
+ h('p', { style: STYLE.description }, t('settingsDescription')),
2056
+ h(
2057
+ 'section',
2058
+ { 'aria-labelledby': headingId, style: STYLE.section },
2059
+ h(
2060
+ 'h3',
2061
+ { id: headingId, style: STYLE.heading },
2062
+ t('langSectionTitle'),
2063
+ ),
2064
+ h(
2065
+ 'p',
2066
+ { style: STYLE.description },
2067
+ t('langSectionDescription'),
2068
+ ),
2069
+ h(
2070
+ 'div',
2071
+ { style: STYLE.rows },
2072
+ h(
2073
+ SettingsRow,
2074
+ {
2075
+ key: 'conversation',
2076
+ id: conversationId,
2077
+ label: t('langConversationLabel'),
2078
+ help: t('langConversationHelp'),
2079
+ control: conversationControl,
2080
+ lines: linesFor('langConversation'),
2081
+ },
2082
+ ),
2083
+ h(
2084
+ SettingsRow,
2085
+ {
2086
+ key: 'comments',
2087
+ id: commentsId,
2088
+ label: t('langCommentsLabel'),
2089
+ help: t('langCommentsHelp'),
2090
+ control: h(
2091
+ 'select',
2092
+ selectProps(
2093
+ commentsId,
2094
+ values.langComments,
2095
+ (value) => edit('langComments', value),
2096
+ ),
2097
+ ...languageOptions(t, { withUnset: 'follow' }),
2098
+ ),
2099
+ lines: linesFor('langComments'),
2100
+ },
2101
+ ),
2102
+ h(
2103
+ SettingsRow,
2104
+ {
2105
+ key: 'docs',
2106
+ id: docsId,
2107
+ label: t('langDocsLabel'),
2108
+ help: t('langDocsHelp'),
2109
+ control: h(
2110
+ 'select',
2111
+ selectProps(
2112
+ docsId,
2113
+ values.langDocs,
2114
+ (value) => edit('langDocs', value),
2115
+ ),
2116
+ ...languageOptions(t, { withUnset: 'follow' }),
2117
+ ),
2118
+ lines: linesFor('langDocs'),
2119
+ },
2120
+ ),
2121
+ ),
2122
+ ),
2123
+ h(
2124
+ 'section',
2125
+ {
2126
+ 'aria-labelledby': dispatchId,
2127
+ style: { ...STYLE.section, ...STYLE.sectionDivider },
2128
+ },
2129
+ h(
2130
+ 'h3',
2131
+ { id: dispatchId, style: STYLE.heading },
2132
+ t('dispatchSectionTitle'),
2133
+ ),
2134
+ h(
2135
+ 'p',
2136
+ { style: STYLE.description },
2137
+ t('dispatchSectionDescription'),
2138
+ ),
2139
+ h(
2140
+ 'p',
2141
+ { style: STYLE.progress },
2142
+ t('dispatchProgress', {
2143
+ pools: poolsSet,
2144
+ ranked: display.length,
2145
+ mode: t(enforceLabelKey[values.dispatchEnforce]),
2146
+ }),
2147
+ ),
2148
+ catalogNotice,
2149
+ h(
2150
+ 'div',
2151
+ { style: STYLE.poolGrid },
2152
+ ...POOL_FIELDS.map(({ field, manualField, effortsField, lane }) => {
2153
+ const { rows, extras } = poolRows(values[field], catalogIndex);
2154
+ return h(PoolCard, {
2155
+ key: field,
2156
+ t,
2157
+ title: t(`pool${lane[0].toUpperCase()}${lane.slice(1)}Title`),
2158
+ description: t(
2159
+ `pool${lane[0].toUpperCase()}${lane.slice(1)}Desc`,
2160
+ ),
2161
+ rows,
2162
+ extras,
2163
+ routes: values[field],
2164
+ efforts: values[effortsField],
2165
+ manual: values[manualField],
2166
+ editable,
2167
+ onToggle: (meta) => {
2168
+ togglePool(field, meta);
2169
+ },
2170
+ onToggleManual: () => {
2171
+ edit(manualField, !values[manualField]);
2172
+ },
2173
+ onSetEffort: (meta, effort) => {
2174
+ setPoolEffort(effortsField, meta, effort);
2175
+ },
2176
+ onMove: (index, delta) => {
2177
+ movePoolRoute(field, index, delta);
2178
+ },
2179
+ onRemove: (index) => {
2180
+ removePoolRoute(field, index);
2181
+ },
2182
+ onSelectAll: () => {
2183
+ setAllPool(field, true);
2184
+ },
2185
+ onClear: () => {
2186
+ setAllPool(field, false);
2187
+ },
2188
+ });
2189
+ }),
2190
+ ),
2191
+ h(
2192
+ 'h4',
2193
+ { id: rankId, style: STYLE.subheading },
2194
+ t('rankTitle'),
2195
+ ),
2196
+ h('p', { style: STYLE.description }, t('rankDescription')),
2197
+ display.length > 0
2198
+ ? h('div', { style: STYLE.rankList }, ...display.map(rankRow))
2199
+ : h('p', { style: STYLE.progress }, t('rankEmpty')),
2200
+ h(
2201
+ 'h4',
2202
+ { style: STYLE.subheading },
2203
+ t('enforceLabel'),
2204
+ ),
2205
+ h(SettingsRow, {
2206
+ key: 'enforce',
2207
+ id: enforceId,
2208
+ label: t('enforceLabel'),
2209
+ control: h(
2210
+ 'select',
2211
+ selectProps(enforceId, values.dispatchEnforce, (mode) => {
2212
+ if (ENFORCE_MODES.includes(mode)) edit('dispatchEnforce', mode);
2213
+ }),
2214
+ ...ENFORCE_MODES.map((mode) =>
2215
+ h(
2216
+ 'option',
2217
+ { key: mode, value: mode },
2218
+ t(enforceLabelKey[mode]),
2219
+ ),
2220
+ ),
2221
+ ),
2222
+ lines: [
2223
+ {
2224
+ text: t(enforceHelpKey[values.dispatchEnforce]),
2225
+ invalid: false,
2226
+ },
2227
+ ],
2228
+ }),
2229
+ ),
2230
+ h(
2231
+ 'section',
2232
+ {
2233
+ 'aria-labelledby': wmId,
2234
+ style: { ...STYLE.section, ...STYLE.sectionDivider },
2235
+ },
2236
+ h('h3', { id: wmId, style: STYLE.heading }, t('wmSectionTitle')),
2237
+ h('p', { style: STYLE.description }, t('wmSectionDescription')),
2238
+ h(
2239
+ 'div',
2240
+ { style: STYLE.rows },
2241
+ ...WM_NUMBER_FIELDS.filter(
2242
+ ({ field }) => field !== 'wmSubagentForceTokens',
2243
+ ).map((spec) =>
2244
+ h(SettingsRow, {
2245
+ key: spec.field,
2246
+ id: `${wmId}-${spec.field}`,
2247
+ label: t(WM_ROW_KEYS[spec.field].label),
2248
+ help: t(WM_ROW_KEYS[spec.field].help),
2249
+ control: wmNumberControl(spec),
2250
+ lines: wmNumberLines(spec),
2251
+ }),
2252
+ ),
2253
+ h(SettingsRow, {
2254
+ key: 'wmDenyMode',
2255
+ id: `${wmId}-deny`,
2256
+ label: t('wmDenyModeLabel'),
2257
+ control: h(
2258
+ 'select',
2259
+ selectProps(`${wmId}-deny`, values.wmDenyMode, (mode) => {
2260
+ if (mode === 'cap' || mode === 'deny')
2261
+ edit('wmDenyMode', mode);
2262
+ }),
2263
+ h('option', { key: 'cap', value: 'cap' }, t('wmDenyModeCap')),
2264
+ h('option', { key: 'deny', value: 'deny' }, t('wmDenyModeDeny')),
2265
+ ),
2266
+ lines: [
2267
+ {
2268
+ text: t(
2269
+ values.wmDenyMode === 'deny'
2270
+ ? 'wmDenyModeHelpDeny'
2271
+ : 'wmDenyModeHelpCap',
2272
+ ),
2273
+ invalid: false,
2274
+ },
2275
+ ],
2276
+ }),
2277
+ h(SettingsRow, {
2278
+ key: 'wmAutoHandover',
2279
+ id: `${wmId}-auto`,
2280
+ label: t('wmAutoHandoverLabel'),
2281
+ help: t('wmAutoHandoverHelp'),
2282
+ control: h('input', {
2283
+ type: 'checkbox',
2284
+ id: `${wmId}-auto`,
2285
+ checked: values.wmAutoHandover,
2286
+ onChange: () => {
2287
+ edit('wmAutoHandover', !values.wmAutoHandover);
2288
+ },
2289
+ disabled: !editable,
2290
+ style: STYLE.checkbox,
2291
+ }),
2292
+ lines: [],
2293
+ }),
2294
+ ),
2295
+ h('h4', { style: STYLE.subheading }, t('wmSubagentTitle')),
2296
+ h(
2297
+ 'div',
2298
+ { style: STYLE.rows },
2299
+ h(SettingsRow, {
2300
+ key: 'wmSubagentCap',
2301
+ id: `${wmId}-subcap`,
2302
+ label: t('wmSubagentCapLabel'),
2303
+ help: t('wmSubagentCapHelp'),
2304
+ control: h('input', {
2305
+ type: 'checkbox',
2306
+ id: `${wmId}-subcap`,
2307
+ checked: values.wmSubagentCap,
2308
+ onChange: () => {
2309
+ edit('wmSubagentCap', !values.wmSubagentCap);
2310
+ },
2311
+ disabled: !editable,
2312
+ style: STYLE.checkbox,
2313
+ }),
2314
+ lines: [],
2315
+ }),
2316
+ h(SettingsRow, {
2317
+ key: 'wmSubagentForceTokens',
2318
+ id: `${wmId}-subforce`,
2319
+ label: t('wmSubagentForceLabel'),
2320
+ help: t('wmSubagentForceHelp'),
2321
+ control: wmNumberControl(
2322
+ WM_NUMBER_FIELDS.find(
2323
+ ({ field }) => field === 'wmSubagentForceTokens',
2324
+ ),
2325
+ t('wmSubagentFollowForce'),
2326
+ ),
2327
+ lines: wmNumberLines(
2328
+ WM_NUMBER_FIELDS.find(
2329
+ ({ field }) => field === 'wmSubagentForceTokens',
2330
+ ),
2331
+ ),
2332
+ }),
2333
+ ),
2334
+ h('p', { style: STYLE.progress }, t('wmCommands')),
2335
+ ),
2336
+ h(
2337
+ 'div',
2338
+ { style: STYLE.footer },
2339
+ h(
2340
+ 'p',
2341
+ {
2342
+ role: 'status',
2343
+ style: failed ? STYLE.messageError : STYLE.message,
2344
+ },
2345
+ failed
2346
+ ? t('saveFailed')
2347
+ : conflicted
2348
+ ? t('saveConflict')
2349
+ : saving
2350
+ ? t('saving')
2351
+ : !form.writable
2352
+ ? t('formReadOnly')
2353
+ : '',
2354
+ ),
2355
+ form.writable
2356
+ ? h(
2357
+ 'div',
2358
+ { style: STYLE.actions },
2359
+ draft === null && !failed && !conflicted
2360
+ ? null
2361
+ : h(
2362
+ 'button',
2363
+ {
2364
+ type: 'button',
2365
+ onClick: discard,
2366
+ disabled: saving,
2367
+ style: {
2368
+ ...STYLE.buttonBase,
2369
+ color: 'var(--dsw-alias-label-secondary)',
2370
+ },
2371
+ },
2372
+ t('discard'),
2373
+ ),
2374
+ h(
2375
+ 'button',
2376
+ {
2377
+ type: 'button',
2378
+ onClick: save,
2379
+ disabled: saveDisabled,
2380
+ style: {
2381
+ ...STYLE.buttonBase,
2382
+ color: 'var(--dsw-alias-brand-primary)',
2383
+ borderColor: 'var(--dsw-alias-brand-primary)',
2384
+ opacity: saveDisabled ? 0.5 : 1,
2385
+ cursor: saveDisabled ? 'default' : 'pointer',
2386
+ },
2387
+ },
2388
+ saving ? t('saving') : t('save'),
2389
+ ),
2390
+ )
2391
+ : null,
2392
+ ),
2393
+ );
2394
+ }
2395
+
2396
+ return {
2397
+ inject: ['slots', 'locale'],
2398
+ apply(ctx) {
2399
+ ctx.effect(() => ctx.locale.register(NS, DICTS));
2400
+ const t = ctx.locale.bind(NS);
2401
+ ctx.slots.inject('conversation.session.header.actions', () =>
2402
+ ctx.slots.register(
2403
+ {
2404
+ name: 'conversation.session.header.actions',
2405
+ id: 'dsh-switchman',
2406
+ order: -9,
2407
+ },
2408
+ () => h(SwitchmanBadge, { locale: ctx.locale, t }),
2409
+ ),
2410
+ );
2411
+ // Settings page: one settings.section entry. The restricted
2412
+ // dynamic-module runtime has no configForms service, so there
2413
+ // is no whileServed gating — the page loads its snapshot from
2414
+ // the Host route family on mount.
2415
+ ctx.slots.inject('settings.section', () =>
2416
+ ctx.slots.register(
2417
+ {
2418
+ name: 'settings.section',
2419
+ id: 'dsh-switchman',
2420
+ order: 50,
2421
+ label: () => t('settingsTitle'),
2422
+ },
2423
+ () => h(SwitchmanSettingsPage, { t, locale: ctx.locale }),
2424
+ ),
2425
+ );
2426
+ // The same configuration surface on the bundle's own Plugins
2427
+ // page card (keyed by package name, rendered between the
2428
+ // bundle's description and its rows).
2429
+ ctx.slots.inject('plugins.bundle.config', () =>
2430
+ ctx.slots.register(
2431
+ {
2432
+ name: 'plugins.bundle.config',
2433
+ key: 'dsh-switchman',
2434
+ },
2435
+ () => h(SwitchmanSettingsPage, { t, locale: ctx.locale }),
2436
+ ),
2437
+ );
2438
+ },
2439
+ };
2440
+ }
2441
+ });