@huai_xin/dsh-model-picker 0.1.3 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +19 -4
  2. package/client.js +1286 -837
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -5,561 +5,472 @@
5
5
  * This bundle is what the Client module loader serves; it is the only browser
6
6
  * entry the loader knows about for this package.
7
7
  */
8
- //# module: src/core/store.js
8
+ //# module: src/core/rank.js
9
9
  /**
10
- * A snapshot store with the contract the slot renderer's hooks expect.
10
+ * Fuzzy ranking for the model search box.
11
11
  *
12
- * The Client's own engine (`@deepseek-ai/dsh-client-store`) is a Harness Client
13
- * package, and a plain-JS plugin must not require those: they change without
14
- * notice and there is no type check here. This is the same four-method surface
15
- * the renderer needs — `getSnapshot`, `subscribe`, `set`, `update` — over a
16
- * state object that is replaced, never mutated.
12
+ * This is the shipped `rankByName` from `@deepseek-ai/dsh-client-ui-primitives`
13
+ * (the `/` menu's own search), vendored verbatim rather than required: the
14
+ * Client module table only carries `react`, and pulling a host package into the
15
+ * offline unit test would make it impossible to run without DSH installed. The
16
+ * algorithm is self-contained — no imports, no state — so copying it keeps the
17
+ * picker's results identical to the shipped menus while the source tree stays
18
+ * dependency-free.
17
19
  *
18
- * @param init - the initial state object.
19
- * @returns the store.
20
+ * What it does, per the original doc comment: the query must be a
21
+ * case-insensitive ORDERED SUBSEQUENCE of the candidate's name (or its `label`
22
+ * when it carries one). Prefix hits rank first, then the strongest alignment
23
+ * score over either key, then the source order of the input.
24
+ *
25
+ * Upstream: `packages/client/ui-primitives/src/rank-by-name.ts`.
20
26
  */
21
- function createStore(init) {
22
- let state = init
23
- const listeners = new Set()
24
-
25
- const publish = (next) => {
26
- state = next
27
- for (const listener of [...listeners]) {
28
- try {
29
- listener()
30
- } catch {
31
- // One failing subscriber must not stop the others.
32
- }
33
- }
34
- }
35
27
 
36
- return {
37
- getSnapshot: () => state,
38
- subscribe: (listener) => {
39
- listeners.add(listener)
40
- return () => { listeners.delete(listener) }
41
- },
42
- set: (next) => publish(next),
43
- update: (mutate) => {
44
- const draft = { ...state }
45
- mutate(draft)
46
- publish(draft)
47
- }
48
- }
28
+ /** Extra weight for name starts and separator boundaries. */
29
+ function boundaryBonus(name, index) {
30
+ return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
49
31
  }
50
32
 
51
33
  /**
52
- * Subscribe to any `{ getSnapshot, subscribe }` source, for a component.
34
+ * Score the strongest ordered-subsequence alignment in O(name × query).
53
35
  *
54
- * @param useState - React's useState, passed in so this module needs no import.
55
- * @param useEffect - React's useEffect.
56
- * @param source - the observable source, or undefined while it is not resolved.
57
- * @returns the current snapshot.
36
+ * Boundary and adjacent matches earn weight; skipped and leading characters
37
+ * cost weight.
38
+ *
39
+ * @param name - the lowercased candidate name.
40
+ * @param query - the lowercased query.
41
+ * @returns the score, or undefined when the query is not a subsequence.
58
42
  */
59
- function readSnapshot(useState, useEffect, source) {
60
- const [value, setValue] = useState(() => source?.getSnapshot?.())
61
- useEffect(() => {
62
- if (source === undefined || source === null) return undefined
63
- const update = () => setValue(source.getSnapshot())
64
- update()
65
- return source.subscribe(update)
66
- }, [source])
67
- return value
68
- }
69
-
70
- //# module: src/core/catalog.js
71
-
72
- /**
73
- * The Host notifications that can change the catalogue.
74
- *
75
- * `llm/adapters-updated` covers a provider adding or removing a model; the other
76
- * three cover the inputs a provider's availability is derived from (settings,
77
- * stored credentials, credential references). The shipped directory subscribes to
78
- * exactly these four, so a model switched off in a provider's own panel
79
- * disappears here on the same tick it disappears there. Listening to the first
80
- * one alone left this picker offering models the Host would refuse until the page
81
- * was reloaded.
82
- */
83
- const INVALIDATION_EVENTS = Object.freeze([
84
- 'llm/adapters-updated',
85
- 'settings/document-updated',
86
- 'credentials/record-updated',
87
- 'credentials/reference-updated'
88
- ])
89
-
90
- /**
91
- * One Host-generation catalogue shared by every session.
92
- *
93
- * Mirrors the shipped `ModelCatalogDirectory`: a single value, one in-flight
94
- * request shared by every reader, a generation counter that discards responses
95
- * from a superseded generation, and a `reasoning` map that remembers each
96
- * model's effort metadata *after* the model leaves the catalogue — so a level
97
- * still shows its name while its model is gone.
98
- *
99
- * @param resolveRemote - returns the client remote face carrying `session.modelCatalog`.
100
- * A THUNK, not the face itself: this catalogue is built in `apply`, where the
101
- * `remote` service is often not visible yet (it arrives later in the client boot
102
- * sequence). Capturing the value here froze `undefined` and every load then
103
- * reported "modelCatalog is unavailable".
104
- * @param onDiagnostic - optional sink for load-stage notes.
105
- * @returns the catalogue controller.
106
- */
107
- function createCatalog(resolveRemote, onDiagnostic) {
108
- const store = createStore({ value: null, status: 'idle', error: null })
109
- const reasoning = new Map()
110
- // Bumped by every invalidation. A response that resolves under an older
111
- // generation is dropped instead of publishing a catalogue the Host has
112
- // already superseded.
113
- let generation = 0
114
- let inflight
115
-
116
- const note = (message) => {
117
- try { onDiagnostic?.(message) } catch { /* diagnostics never break the load */ }
118
- }
119
-
120
- /** The remote face as it is right now, or undefined while the service is absent. */
121
- const currentRemote = () => {
122
- try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
123
- }
124
-
125
- const reasoningFor = (selection) => selection === null || selection === undefined
126
- ? undefined
127
- : reasoning.get(JSON.stringify([selection.provider, selection.model]))
128
-
129
- const load = () => {
130
- const state = store.getSnapshot()
131
- if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
132
- if (inflight !== undefined) return inflight
133
- const remote = currentRemote()
134
- const run = remote?.session?.modelCatalog
135
- if (typeof run !== 'function') {
136
- note('modelCatalog 不可用:remote 尚未就绪')
137
- store.set({ value: state.value, status: 'error', error: 'modelCatalog is unavailable' })
138
- return Promise.resolve(undefined)
139
- }
140
- const at = generation
141
- // Keep the previous value visible while reloading: an invalidation is not an
142
- // error, and blanking the list would flash on every provider edit.
143
- store.update((draft) => { draft.status = 'loading'; draft.error = null })
144
- const operation = Promise.resolve(run.call(remote.session)).then((result) => {
145
- if (result?.ok !== true) {
146
- note(`modelCatalog 失败:${result?.error?.code ?? '?'} ${result?.error?.message ?? ''}`)
147
- if (at === generation) {
148
- store.set({
149
- value: store.getSnapshot().value,
150
- status: 'error',
151
- error: result?.error?.message ?? 'failed'
152
- })
153
- }
154
- return undefined
155
- }
156
- const groups = Array.isArray(result.value?.groups) ? result.value.groups : []
157
- note(`modelCatalog 成功:${groups.length} 组,gen ${at}/${generation}`)
158
- if (at === generation) {
159
- for (const group of groups) {
160
- for (const model of group.models) {
161
- reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning)
162
- }
163
- }
164
- store.set({ value: result.value, status: 'ready', error: null })
165
- } else {
166
- // The result was dropped: another load superseded it. Say so, because a
167
- // silently discarded success is what leaves the store on `loading`.
168
- note(`modelCatalog 结果被丢弃(gen ${at} != ${generation})`)
169
- }
170
- return result.value
171
- }).catch((error) => {
172
- note(`modelCatalog 抛错:${error instanceof Error ? error.message : String(error)}`)
173
- if (at === generation) {
174
- store.set({
175
- value: store.getSnapshot().value,
176
- status: 'error',
177
- error: error instanceof Error ? error.message : String(error)
178
- })
179
- }
180
- return undefined
181
- }).finally(() => {
182
- if (at === generation && inflight === operation) inflight = undefined
183
- })
184
- inflight = operation
185
- return operation
186
- }
187
- const invalidate = (clear) => {
188
- generation += 1
189
- inflight = undefined
190
- store.set({
191
- value: clear === true ? null : store.getSnapshot().value,
192
- status: 'idle',
193
- error: null
194
- })
195
- }
196
-
197
- /** Re-read on the next explicit load, keeping the stale value on screen. */
198
- const refresh = () => {
199
- note(`refresh 被调用(gen ${generation} → ${generation + 1})`)
200
- invalidate(false)
201
- load()
202
- }
203
-
204
- /** Drop everything: the value belonged to a Host generation that is gone. */
205
- const resetGeneration = () => { reasoning.clear(); invalidate(true); load() }
206
-
207
- /**
208
- * Subscribe the invalidation events.
209
- *
210
- * Call this from the plugin's `apply` body, not from a scope or slot callback.
211
- * The gateway registers a forwarded-event listener in the CALLING fiber's
212
- * private event domain, and only the plugin's own `apply` fiber is in the
213
- * domain the dispatcher publishes to — a subscription installed from a
214
- * `ctx.inject` callback is accepted (it returns a disposer) but never fires.
215
- *
216
- * @param target - the client remote carrying `$on`.
217
- * @param onEvent - optional observer called with each delivered event name.
218
- * @returns a disposer removing every subscription.
219
- */
220
- const attach = (target, onEvent) => {
221
- const offs = []
222
- const failed = []
223
- for (const event of INVALIDATION_EVENTS) {
224
- try {
225
- if (typeof target?.$on !== 'function') {
226
- failed.push(`${event}: 目标没有 $on`)
227
- continue
228
- }
229
- const off = target.$on(event, () => {
230
- onEvent?.(event)
231
- refresh()
232
- })
233
- if (typeof off === 'function') offs.push(off)
234
- else failed.push(`${event}: 返回的不是 disposer`)
235
- } catch (error) {
236
- failed.push(`${event}: ${error instanceof Error ? error.message : String(error)}`)
237
- }
238
- }
239
- note(`订阅结果:成功 ${offs.length}/${INVALIDATION_EVENTS.length}${failed.length > 0 ? `;问题:${failed.join(' | ')}` : ''}`)
240
- return () => { for (const off of offs) off() }
241
- }
242
-
243
- /** Whether this target can carry event subscriptions at all. */
244
- const canAttach = (target) => typeof target?.$on === 'function'
245
-
246
- return { store, reasoningFor, load, refresh, resetGeneration, attach, canAttach }
43
+ function alignmentScore(name, query) {
44
+ if (query.length > name.length) return undefined
45
+ const noMatch = Number.NEGATIVE_INFINITY
46
+ let previous = Array(name.length).fill(noMatch)
47
+ for (let index = 0; index < name.length; index += 1) {
48
+ if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
49
+ }
50
+ for (let queryIndex = 1; queryIndex < query.length; queryIndex += 1) {
51
+ const current = Array(name.length).fill(noMatch)
52
+ let left = noMatch
53
+ let leftLeft = noMatch
54
+ let bestGapped = noMatch
55
+ for (const [index, prior] of previous.entries()) {
56
+ if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2)
57
+ if (name.charAt(index) === query.charAt(queryIndex)) {
58
+ const bonus = 1 + boundaryBonus(name, index)
59
+ let score = noMatch
60
+ if (left !== noMatch) score = left + bonus + 4
61
+ if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index)
62
+ current[index] = score
63
+ }
64
+ leftLeft = left
65
+ left = prior
66
+ }
67
+ previous = current
68
+ }
69
+ let best = noMatch
70
+ for (const score of previous) best = Math.max(best, score)
71
+ return best === noMatch ? undefined : best
247
72
  }
248
73
 
249
- //# module: src/core/selection.js
250
74
  /**
251
- * Reading a model selection out of whatever shape the Host handed over.
75
+ * Rank named items by a search query.
252
76
  *
253
- * The projection carries the selection; older and newer Host generations have
254
- * spelled the fields differently (`provider` / `providerId` / `provider_id`),
255
- * so every known shape is accepted and an unknown one simply yields `null`
256
- * rather than guessing.
257
- *
258
- * @param projection - a projection frame, a selection, or nothing.
259
- * @returns `{ provider, model, reasoningEffort }`, or null when unreadable.
77
+ * @param items - candidates in source order; an item's optional `label` is a
78
+ * second search key beside its `name`.
79
+ * @param rawQuery - the text typed into the search box, matched case-insensitively.
80
+ * @returns the matching items: prefix hits first, then by alignment score, then
81
+ * in source order. The input list itself for an empty query.
260
82
  */
261
- function readSelection(projection) {
262
- const value = projection?.next ?? projection?.current ?? projection
263
- if (value === null || typeof value !== 'object') return null
264
- const provider = value.provider ?? value.providerId ?? value.provider_id
265
- const model = value.model ?? value.modelId ?? value.model_id
266
- if (typeof provider !== 'string' || typeof model !== 'string') return null
267
- return { provider, model, reasoningEffort: value.reasoningEffort }
83
+ function rankByName(items, rawQuery) {
84
+ const query = String(rawQuery ?? '').toLowerCase()
85
+ if (query === '') return items
86
+ const ranked = []
87
+ items.forEach((item, index) => {
88
+ const name = String(item?.name ?? item?.id ?? '')
89
+ const keys = item?.label === undefined ? [name] : [name, String(item.label)]
90
+ let prefix = false
91
+ let score
92
+ for (const key of keys) {
93
+ const lower = key.toLowerCase()
94
+ const keyScore = alignmentScore(lower, query)
95
+ if (keyScore === undefined) continue
96
+ prefix ||= lower.startsWith(query)
97
+ score = score === undefined ? keyScore : Math.max(score, keyScore)
98
+ }
99
+ if (score !== undefined) ranked.push({ item, index, prefix, score })
100
+ })
101
+ ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) ||
102
+ right.score - left.score ||
103
+ left.index - right.index)
104
+ return ranked.map((match) => match.item)
268
105
  }
269
106
 
107
+ //# module: src/ui/Chevron.js
270
108
  /**
271
- * Whether this session may change its model at all.
272
- *
273
- * An addressed subagent session cannot: the Host rejects the RPC. The shipped
274
- * picker renders nothing in that case rather than offering a control whose every
275
- * click fails. `subagentAddress` is the same probe it uses; a client generation
276
- * without it counts as available, so an older build keeps showing the control
277
- * instead of silently losing it.
109
+ * The chevron that turns when its panel is open.
278
110
  *
279
- * @param sessions - the sessions service face.
280
- * @param sessionId - the session being rendered.
281
- * @returns whether the model control should be offered.
111
+ * @param props - `{ h, open }`: the createElement function and the open flag.
112
+ * @returns the svg element.
282
113
  */
283
- function isAvailable(sessions, sessionId) {
284
- try {
285
- if (typeof sessions?.subagentAddress !== 'function') return true
286
- return sessions.subagentAddress(sessionId) === undefined
287
- } catch {
288
- return true
289
- }
114
+ function Chevron({ h, open }) {
115
+ return h('svg', {
116
+ className: `dmp-chevron${open ? ' dmp-chevron-open' : ''}`,
117
+ viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true
118
+ }, h('path', {
119
+ d: 'M4 6.5L8 10.5L12 6.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6,
120
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
121
+ }))
290
122
  }
291
123
 
292
- //# module: src/core/directory.js
293
-
294
-
124
+ //# module: src/ui/anchor.js
295
125
  /**
296
- * One session's directory, mirroring the shipped `ModelDirectory`.
126
+ * Anchor a popover to the centre of its trigger, clamped to the viewport.
297
127
  *
298
- * The catalogue is Host-wide, but the *selection* is per session: which model is
299
- * in effect, whether that model is still routable, what is in flight, and which
300
- * provider groups failed to load. The shipped picker renders all four, and a
301
- * replacement that collapsed them into "the catalogue plus the projection" loses
302
- * the in-flight state (so a slow switch shows nothing) and the failure list (so a
303
- * broken provider group is silently missing).
128
+ * Both popovers in this picker open ABOVE their button, and both are wider than
129
+ * the button they belong to. Anchoring by a fixed side (`left: 0` or `right: 0`)
130
+ * makes the panel drift away from the control the user just pressed, and near
131
+ * either screen edge it runs off the window entirely.
304
132
  *
305
- * The store contract is the same one the renderer's hooks read:
306
- * `{ current, routable, groups, failures, status, pending, error }`.
133
+ * The hook measures the trigger, centres the panel on it, then shifts the result
134
+ * so the panel stays inside the viewport with a small margin. It returns:
307
135
  *
308
- * @param catalog - the shared catalogue controller.
309
- * @param projectedFace - the session's `modelSelection` projection face.
310
- * @param remote - the client remote face carrying `session.selectModel`.
311
- * @param sessionId - the owning session.
312
- * @returns the directory controller.
136
+ * - `ref` — attach to the trigger element,
137
+ * - `panelRef`— attach to the panel element (its width depends on content),
138
+ * - `style` — spread onto the panel: centring transform, clamp offset, and
139
+ * `visibility` gated on the first measurement so the panel never
140
+ * paints at the wrong place for a frame.
141
+ *
142
+ * @param React - the React runtime.
143
+ * @param options - `{ open, fallbackWidth, deps }`.
144
+ * @returns `{ ref, panelRef, style }`.
313
145
  */
314
- function createDirectory(catalog, projectedFace, remote, sessionId) {
315
- const store = createStore({
316
- current: null,
317
- routable: null,
318
- groups: [],
319
- failures: [],
320
- status: 'idle',
321
- pending: null,
322
- error: null
323
- })
324
- // Latest selection wins: an older response must never overwrite a newer one.
325
- let generation = 0
326
- let disposed = false
327
-
328
- /**
329
- * Recompute the effective selection from the two inputs that define it.
330
- *
331
- * `intended` is the session's explicit choice, else the catalogue default —
332
- * exactly the shipped precedence. `routable` says whether the Host would still
333
- * accept it, which is what lets the trigger distinguish "this model" from "a
334
- * model that no longer exists".
335
- */
336
- const sync = () => {
337
- if (disposed) return
338
- const catalogState = catalog.store.getSnapshot()
339
- const projected = readSelection(projectedFace?.getSnapshot?.())
340
- const intended = projected ?? readSelection(catalogState.value?.default)
341
- const reasoning = catalog.reasoningFor(intended)
342
- const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort
343
- const retainedEffort = effort === undefined
344
- ? undefined
345
- : reasoning?.efforts?.find((level) => level.id === effort)?.name ?? effort
346
- const ready = catalogState.status === 'ready' && catalogState.value !== null
347
- if (!ready || intended === null) {
348
- store.set({
349
- current: catalogState.value === null ? null : store.getSnapshot().current,
350
- ...retainedEffort === undefined ? {} : { retainedEffort },
351
- routable: null,
352
- groups: catalogState.value?.groups ?? [],
353
- failures: catalogState.value?.failures ?? [],
354
- status: catalogState.status === 'error' ? 'error' : 'loading',
355
- pending: store.getSnapshot().pending,
356
- error: catalogState.error
357
- })
358
- return
359
- }
360
- const routable = (catalogState.value.groups ?? []).some((group) =>
361
- group.id === intended.provider && (group.models ?? []).some((model) => model.id === intended.model))
362
- store.set({
363
- current: intended,
364
- ...retainedEffort === undefined ? {} : { retainedEffort },
365
- routable,
366
- groups: catalogState.value.groups,
367
- failures: catalogState.value.failures ?? [],
368
- status: store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready',
369
- pending: store.getSnapshot().pending,
370
- error: null
371
- })
372
- }
146
+ function createAnchoredPanel(React, options) {
147
+ const { useState, useEffect, useRef } = React
148
+ const { open, fallbackWidth = 280, margin = 8, deps = [] } = options
373
149
 
374
- const unsubscribeCatalog = catalog.store.subscribe(() => { sync() })
375
- const unsubscribeProjection = projectedFace?.subscribe?.(() => { sync() })
376
- sync()
150
+ const [anchor, setAnchor] = useState({ offset: 0, ready: false })
151
+ const triggerRef = useRef(null)
152
+ const panelRef = useRef(null)
377
153
 
378
- /**
379
- * Select the complete provider/model/effort triple.
380
- *
381
- * The pending selection is published before the RPC and cleared after it, so
382
- * the trigger can show which row is settling. A response that arrives after a
383
- * newer selection (or after disposal) is dropped rather than written, which is
384
- * the bug a bare `busy` flag cannot avoid: two fast clicks would otherwise let
385
- * the first reply overwrite the second choice.
386
- *
387
- * @param selection - provider, model, and optional reasoningEffort.
388
- * @returns the Host result, or a synthesized failure.
389
- */
390
- const select = async (selection) => {
391
- // The adapter's default effort is part of the selection the shipped picker
392
- // submits, so a model switch also settles the level instead of leaving the
393
- // Host to guess on the next call.
394
- const nextEffort = selection.reasoningEffort ?? catalog.reasoningFor(selection)?.defaultEffort
395
- const next = { ...selection, ...nextEffort === undefined ? {} : { reasoningEffort: nextEffort } }
396
- const generationAt = ++generation
397
- store.update((draft) => {
398
- draft.status = 'selecting'
399
- draft.pending = next
400
- draft.error = null
401
- })
402
- const run = remote?.session?.selectModel
403
- if (typeof run !== 'function') {
404
- store.update((draft) => {
405
- draft.status = 'error'
406
- draft.pending = null
407
- draft.error = 'selectModel is unavailable'
408
- })
409
- return { ok: false, error: { code: 'unavailable', message: 'selectModel is unavailable' } }
410
- }
411
- let result
412
- try {
413
- result = await run.call(remote.session, {
414
- sessionId,
415
- provider: next.provider,
416
- model: next.model,
417
- ...next.reasoningEffort === undefined ? {} : { reasoningEffort: next.reasoningEffort }
418
- })
419
- } catch (error) {
420
- result = {
421
- ok: false,
422
- error: { code: 'threw', message: error instanceof Error ? error.message : String(error) }
423
- }
154
+ useEffect(() => {
155
+ if (!open) {
156
+ setAnchor({ offset: 0, ready: false })
157
+ return undefined
424
158
  }
425
- if (disposed || generationAt !== generation) {
426
- return result?.ok === true ? { ok: true, value: undefined } : result
159
+ const measure = () => {
160
+ const trigger = triggerRef.current
161
+ if (trigger === null || typeof trigger.getBoundingClientRect !== 'function') return
162
+ const rect = trigger.getBoundingClientRect()
163
+ const viewport = (typeof window !== 'undefined' && window.innerWidth)
164
+ || (typeof document !== 'undefined' && document.documentElement?.clientWidth)
165
+ || 0
166
+ if (viewport === 0) return
167
+
168
+ // Read the panel's real width: it depends on content (a five-level slider is
169
+ // wider than a three-level one). `visibility: hidden` does not affect
170
+ // `getBoundingClientRect`, so the width is available before the first paint.
171
+ // The ref is attached in the same commit the panel is rendered in, so it is
172
+ // set by the time this effect runs.
173
+ const panel = panelRef.current
174
+ const measured = panel !== null && panel !== undefined
175
+ && typeof panel.getBoundingClientRect === 'function'
176
+ ? panel.getBoundingClientRect().width
177
+ : 0
178
+ const width = measured > 0 ? measured : Math.min(fallbackWidth, viewport - 32)
179
+
180
+ const centre = rect.left + rect.width / 2
181
+ const centred = centre - width / 2
182
+ const maxLeft = viewport - width - margin
183
+ const clamped = Math.max(margin, Math.min(centred, Math.max(margin, maxLeft)))
184
+ setAnchor({ offset: clamped - centred, ready: true })
427
185
  }
428
- if (result?.ok !== true) {
429
- store.update((draft) => {
430
- draft.status = 'error'
431
- draft.pending = null
432
- draft.error = `${result?.error?.code ?? 'error'}: ${result?.error?.message ?? 'failed'}`
433
- })
434
- return result
435
- }
436
- store.update((draft) => {
437
- draft.status = 'ready'
438
- draft.pending = null
439
- draft.error = null
440
- })
441
- /**
442
- * Do NOT re-project here.
443
- *
444
- * `sync()` recomputes `current` from the session's projected selection. The
445
- * Host answers `selectModel` before it publishes that projection, so calling
446
- * `sync()` at this point reads the OLD selection and writes it straight back
447
- * over the one just chosen — the picker then looks like the click did
448
- * nothing. The projection's own subscription calls `sync()` when the Host
449
- * actually publishes, which is the only moment the new value is real.
450
- *
451
- * Until that push arrives, `current` is the selection the Host just
452
- * acknowledged. `selected` in the answer is authoritative when present;
453
- * otherwise the triple we sent is what the Host accepted.
454
- */
455
- const settled = readSelection(result?.value?.selected) ?? next
456
- store.update((draft) => {
457
- draft.current = settled
458
- draft.routable = true
459
- })
460
- return { ok: true, value: undefined }
461
- }
462
186
 
463
- /** Invalidate an in-flight response from a previous Host generation. */
464
- const resetConnected = () => {
465
- if (disposed) return
466
- generation += 1
467
- store.update((draft) => {
468
- if (draft.status === 'selecting') draft.status = 'idle'
469
- draft.pending = null
470
- draft.error = null
471
- })
472
- sync()
473
- }
187
+ measure()
188
+ // Second pass after layout: the first can see the min-width instead of the
189
+ // real width when the content is wider than the minimum.
190
+ const raf = typeof requestAnimationFrame === 'function'
191
+ ? requestAnimationFrame(measure)
192
+ : undefined
193
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
194
+ window.addEventListener('resize', measure)
195
+ return () => {
196
+ window.removeEventListener('resize', measure)
197
+ if (raf !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
198
+ }
199
+ }
200
+ return undefined
201
+ // `deps` carries content-dependent values (level count, model count) that
202
+ // change the panel's width without changing `open`.
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [open, ...deps])
474
205
 
475
- /** Ensure the shared catalogue is loaded, then re-project. */
476
- const load = async () => {
477
- await catalog.load()
478
- sync()
479
- return store.getSnapshot()
206
+ return {
207
+ ref: triggerRef,
208
+ panelRef,
209
+ style: {
210
+ left: '50%',
211
+ right: 'auto',
212
+ transform: `translateX(calc(-50% + ${anchor.offset}px))`,
213
+ visibility: anchor.ready ? 'visible' : 'hidden'
214
+ }
480
215
  }
216
+ }
481
217
 
482
- /** Scope teardown: late settlements lose write access to the store. */
483
- const dispose = () => {
484
- disposed = true
485
- unsubscribeCatalog?.()
486
- unsubscribeProjection?.()
487
- }
218
+ //# module: src/ui/caption-glyphs.js
219
+ /**
220
+ * The panel heading as vector art.
221
+ *
222
+ * Why a path and not text: Montserrat has no CJK glyphs, so the four
223
+ * characters cannot get the brand face from a font stack. These outlines were
224
+ * taken from Noto Sans SC — the closest geometric match available — with a
225
+ * rounded stroke applied so the joins read like Montserrat's.
226
+ *
227
+ * Filled with `currentColor`, so the mark follows the theme exactly as the text
228
+ * beside it does, and holds its shape at any zoom or DPI.
229
+ */
230
+ const CAPTION_VIEWBOX = '0 0 3891 910'
488
231
 
489
- return { store, select, load, resetConnected, dispose, sync }
490
- }
232
+ /** One entry per character: horizontal offset, baseline, and outline. */
233
+ const CAPTION_GLYPHS = [
234
+ { x: -61, y: 833, d: 'M293 248 L323 248 L323 15 Q323 -5 350 -18 L398 -18 Q409 -18 474 -18 Q553 -18 623 -18 L638 -18 Q666 -18 694 2 Q706 64 708 127 Q713 124 723 118 Q733 114 737 113 Q734 48 718 -22 Q679 -48 639 -48 Q633 -48 585 -48 Q520 -48 454 -48 Q406 -48 401 -48 Q360 -48 312 -38 Q293 -11 293 16 Z M379 294 L399 312 Q439 292 523 240 Q597 187 621 164 L601 143 Q578 165 504 219 Q419 273 379 294 Z M754 238 L780 249 Q811 211 872 120 Q921 33 933 -1 L905 -15 Q892 19 845 107 Q785 199 754 238 Z M175 233 L200 225 Q189 186 156 94 Q113 7 87 -25 L61 -13 Q88 19 131 105 Q164 196 175 233 Z M183 557 L183 386 L808 386 L808 557 Z M183 755 L183 586 L808 586 L808 755 Z M154 784 L837 784 L837 357 L154 357 Z M483 773 L513 773 L513 372 L483 372 Z' },
235
+ { x: 939, y: 833, d: 'M748 205 L778 205 Q778 205 777 198 Q777 189 776 186 Q761 78 730 -23 L708 -42 Q695 -52 668 -58 L645 -59 Q622 -59 521 -56 L463 -52 Q462 -45 458 -29 L453 -22 Q493 -26 569 -30 Q626 -31 638 -31 Q658 -31 679 -28 L686 -23 Q706 -8 733 92 L748 200 Z M313 205 L759 205 L759 176 L306 176 Z M368 361 L845 361 L845 331 L354 331 Z M73 519 L933 519 L933 489 L73 489 Z M166 698 L705 698 L705 669 L166 669 Z M442 831 L472 831 L472 505 L442 505 Z M857 778 L882 763 Q806 670 620 508 Q410 368 185 252 L68 206 Q66 210 58 219 Q49 229 44 232 Q163 276 392 390 Q602 529 784 689 L857 778 Z M360 361 L392 361 Q375 314 333 211 L317 176 L285 176 Q304 214 344 317 L360 361 Z' },
236
+ { x: 1939, y: 833, d: 'M378 -13 Q440 -9 608 3 Q799 17 896 25 L895 -3 Q803 -10 618 -25 Q451 -38 385 -43 Z M783 134 L808 148 Q838 116 894 40 Q940 -33 952 -62 L926 -77 Q914 -48 869 26 Q812 102 783 134 Z M456 406 L456 220 L863 220 L863 406 Z M427 435 L892 435 L892 190 L427 190 Z M477 747 L477 578 L837 578 L837 747 Z M448 776 L867 776 L867 549 L448 549 Z M642 560 L672 560 L672 -14 L642 -16 Z M84 324 L323 324 L323 294 L84 294 Z M315 324 L345 324 Q345 324 344 317 Q344 307 343 304 Q336 186 319 43 Q299 -29 285 -43 Q276 -53 252 -62 L233 -62 Q217 -63 157 -61 L124 -58 Q123 -52 120 -35 L115 -29 Q153 -32 216 -34 L228 -34 Q241 -34 256 -31 L262 -24 Q273 -13 291 59 Q308 201 315 317 Z M97 551 L127 551 Q123 510 110 415 Q97 327 91 295 L61 295 Q68 328 82 417 Q93 511 97 551 Z M110 551 L329 551 L329 747 L64 747 L64 776 L358 776 L358 522 L110 522 Z' },
237
+ { x: 2939, y: 833, d: 'M200 551 L928 551 L928 522 L200 522 Z M220 259 L816 259 L816 229 L220 229 Z M386 659 L416 659 L416 376 L720 376 L720 659 L749 659 L749 347 L386 347 Z M807 259 L814 259 L820 261 L839 248 Q797 173 657 65 Q481 -8 286 -53 L187 -65 Q186 -60 178 -44 L174 -38 Q269 -28 461 14 Q633 83 768 184 L807 252 Z M371 228 Q422 151 598 39 Q825 -31 952 -45 Q949 -48 942 -58 Q936 -67 934 -71 Q808 -54 579 19 Q398 135 345 216 Z M155 718 L941 718 L941 688 L155 688 Z M141 718 L171 718 L171 441 Q171 385 164 254 Q144 117 106 -15 L74 -72 Q72 -69 62 -63 Q53 -57 49 -56 Q81 0 117 128 Q136 260 141 386 L141 441 Z M484 824 L512 833 Q531 805 570 737 L579 714 L551 702 Q541 726 504 796 L484 824 Z' },
238
+ ]
491
239
 
492
- //# module: src/core/directories.js
240
+ //# module: src/ui/CaptionMark.js
493
241
 
494
242
  /**
495
- * The `modelDirectories` service: one directory per session, shared by
496
- * everything that renders a model seat.
243
+ * The panel heading, drawn as vector art.
244
+ *
245
+ * Montserrat has no CJK glyphs, so a font stack cannot give these four characters
246
+ * the host's brand face — they always fall back to the platform UI font. This
247
+ * renders them from outlines instead, so the heading keeps one shape everywhere and
248
+ * can be styled like a mark rather than a run of text.
497
249
  *
498
- * The shipped plugin registers exactly this service, and reusing its instance is
499
- * what keeps a replacement picker and the `/model` popup agreeing on one
500
- * selection state. When it is absent (the shipped bundle disabled, an older
501
- * build), this plugin provides its own under the same name so the rest of the
502
- * code has one contract either way.
250
+ * The fill is `currentColor`: the mark inherits whatever colour the surrounding
251
+ * element already resolved from the theme tokens, so light and dark follow
252
+ * automatically and no JS has to read the theme.
503
253
  *
504
- * @param catalog - the shared catalogue controller.
505
- * @param sessions - the sessions service face.
506
- * @param resolveRemote - returns the client remote face. A thunk for the same
507
- * reason the catalogue takes one: the service is often not visible yet when
508
- * this object is built, and capturing it froze `undefined`.
509
- * @returns the service object with `directoryFor`.
254
+ * The text is still exposed to assistive tech through `aria-label` and a `<title>`,
255
+ * because the glyphs themselves carry no semantics.
256
+ *
257
+ * @param props - `{ h, label, className, height }`.
258
+ * @returns the SVG element tree.
510
259
  */
511
- function createDirectoryService(catalog, sessions, resolveRemote) {
512
- /** The remote face as it is right now, or undefined while the service is absent. */
513
- const currentRemote = () => {
514
- try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
515
- }
260
+ function createCaptionMark(React) {
261
+ const h = React.createElement
516
262
 
517
- /**
518
- * Keyed by session id, not by binding object.
519
- *
520
- * The shipped resolver keys a WeakMap by `sessions.binding(id)` because it
521
- * disposes each directory with the session scope. Keying by the binding here
522
- * would be wrong for a different reason: a binding accessor is free to return a
523
- * fresh wrapper per call, and each call would then allocate a new directory —
524
- * every remount of the seat losing the in-flight selection and re-projecting
525
- * from scratch. The id is the stable identity; `directoryFor` is called per
526
- * render, so it must be cheap and idempotent.
527
- */
528
- const directories = new Map()
529
-
530
- const directoryFor = (sessionId) => {
531
- const key = String(sessionId)
532
- const existing = directories.get(key)
533
- if (existing !== undefined) return existing
534
- const binding = (() => {
535
- try { return sessions?.binding?.(sessionId) } catch { return undefined }
536
- })()
537
- const projectedFace = (() => {
538
- try { return binding?.session?.projections?.faceOf?.('modelSelection') } catch { return undefined }
539
- })()
540
- const directory = createDirectory(catalog, projectedFace, currentRemote(), sessionId)
541
- directories.set(key, directory)
542
- return directory
263
+ return function CaptionMark({ label, className = 'dmp-caption-mark', height = 19 }) {
264
+ // The outlines use font units with Y pointing up, so each glyph is flipped on
265
+ // its own baseline. Doing it per glyph keeps the path data verbatim.
266
+ const glyphs = CAPTION_GLYPHS.map((glyph, index) => h('path', {
267
+ key: index,
268
+ transform: `translate(${glyph.x} ${glyph.y}) scale(1 -1)`,
269
+ d: glyph.d
270
+ }))
271
+
272
+ return h('svg', {
273
+ className,
274
+ viewBox: CAPTION_VIEWBOX,
275
+ height,
276
+ // Width follows the viewBox ratio, so `height` alone is enough to size it.
277
+ role: 'img',
278
+ 'aria-label': label,
279
+ focusable: 'false'
280
+ },
281
+ h('title', null, label),
282
+ h('g', {
283
+ // A rounded stroke widens the strokes and softens every join, which is what
284
+ // brings the geometric Noto skeleton closer to Montserrat's weight.
285
+ fill: 'currentColor',
286
+ stroke: 'currentColor',
287
+ strokeWidth: 76,
288
+ strokeLinejoin: 'round',
289
+ strokeLinecap: 'round'
290
+ }, glyphs))
543
291
  }
544
-
545
- return { directoryFor, directories }
546
292
  }
547
293
 
548
- //# module: src/ui/Chevron.js
294
+ //# module: src/ui/EffortSlider.js
549
295
  /**
550
- * The chevron that turns when its panel is open.
296
+ * A discrete reasoning slider over the model's declared levels.
551
297
  *
552
- * @param props - `{ h, open }`: the createElement function and the open flag.
553
- * @returns the svg element.
298
+ * The visuals inherit the Host theme. A real range input owns pointer and
299
+ * keyboard interaction; the thin track and thumb are its painted layer. Labels
300
+ * remain clickable jump targets.
301
+ *
302
+ * ## Why this component keeps its own position
303
+ *
304
+ * Every stop submits a selection to the Host, and the Host answers a round trip
305
+ * later. Two things follow from that, and both were visible as "the thumb does
306
+ * not follow the mouse":
307
+ *
308
+ * - A CONTROLLED input (`value={index}`) gets the old index written back on
309
+ * every parent render, so the thumb snaps backwards mid-drag.
310
+ * - Re-submitting on every pixel of travel queues a request per stop, and the
311
+ * directory flips to `selecting` in between — which used to disable the input
312
+ * and kill the gesture after the first stop.
313
+ *
314
+ * So the input is uncontrolled and this component tracks the live stop itself.
315
+ * `onInput` updates local state only (instant, no Host involved); the commit to
316
+ * the Host happens once, on `onChange` — the event browsers fire when the pointer
317
+ * is released. A drag is therefore always smooth, and one gesture is one request.
318
+ *
319
+ * The one thing the uncontrolled input cannot do on its own is follow a level
320
+ * chosen WITHOUT a drag — clicking a label under the rail, or a level arriving
321
+ * from `/model`. Those change `activeId` while the input's internal value still
322
+ * points at the old stop, so the next key press would start from there. A ref
323
+ * writes the value back whenever no drag is in progress; it is never written
324
+ * during a gesture, which is exactly what keeps the thumb glued to the pointer.
325
+ *
326
+ * @param props - `{ h, efforts, activeId, disabled, onChange, t, caption }`.
327
+ * @returns the slider element tree.
554
328
  */
555
- function Chevron({ h, open }) {
556
- return h('svg', {
557
- className: `dmp-chevron${open ? ' dmp-chevron-open' : ''}`,
558
- viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true
559
- }, h('path', {
560
- d: 'M4 6.5L8 10.5L12 6.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6,
561
- 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
562
- }))
329
+
330
+ function createEffortSlider(React) {
331
+ const h = React.createElement
332
+ const CaptionMark = createCaptionMark(React)
333
+ const { useState, useEffect, useRef } = React
334
+
335
+ return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
336
+ const count = efforts.length
337
+ const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
338
+
339
+ /**
340
+ * The stop under the pointer, which is ahead of `activeId` while dragging.
341
+ *
342
+ * `null` means "no drag in progress, follow the prop".
343
+ */
344
+ const [dragging, setDragging] = useState(null)
345
+ const index = dragging ?? incoming
346
+ const active = efforts[index]
347
+ const activeName = active?.name ?? active?.id ?? ''
348
+ const inputRef = useRef(null)
349
+
350
+ // A prop change from outside (another entry changed the level, the session
351
+ // switched model) wins over a stale drag value.
352
+ useEffect(() => { setDragging(null) }, [activeId, efforts.length])
353
+
354
+ // The DOM value follows the level unless a gesture owns it. Writing during a
355
+ // drag would fight the pointer, so `dragging` gates it.
356
+ useEffect(() => {
357
+ if (dragging !== null) return
358
+ const input = inputRef.current
359
+ if (input !== null && typeof input === 'object' && 'value' in input) {
360
+ input.value = String(incoming)
361
+ }
362
+ }, [incoming, dragging])
363
+
364
+ // A model with just one effort keeps its stop in the middle.
365
+ const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
366
+
367
+ return h('div', {
368
+ className: 'dmp-slider',
369
+ 'aria-disabled': disabled === true,
370
+ style: { '--dmp-effort-at': position(index) }
371
+ },
372
+ h('div', { className: 'dmp-slider-head' },
373
+ h('span', { className: 'dmp-slider-caption' },
374
+ // The vector marks are CJK outlines; a Latin heading is plain text.
375
+ caption === undefined
376
+ ? h(CaptionMark, { label: t.effortHeading, height: 16 })
377
+ : h('span', { className: 'dmp-caption-text' }, caption)),
378
+ h('span', { className: 'dmp-slider-value' }, activeName)),
379
+
380
+ h('div', { className: 'dmp-slider-rail' },
381
+ /**
382
+ * The moving parts live in an INSET track.
383
+ *
384
+ * Stops are drawn inside the rail rather than on its very edge — a dot
385
+ * sitting flush against the rounded end reads as part of the border, not
386
+ * as a stop. The track is inset by the knob's radius on both sides, which
387
+ * is also exactly the knob's own travel: the native range input spans the
388
+ * rail with a thumb that size, so its centre runs from inset to width
389
+ * minus inset. Keeping all three — fill, dots, knob — on THIS box means
390
+ * the percentage positions from `--dmp-effort-at` line up without any
391
+ * per-element inset arithmetic.
392
+ */
393
+ h('div', { className: 'dmp-slider-track' },
394
+ h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
395
+ h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
396
+ efforts.map((effort, at) => h('span', {
397
+ key: effort.id,
398
+ className: `dmp-slider-dot${at <= index ? ' dmp-slider-dot-lit' : ''}`,
399
+ style: { left: position(at) }
400
+ }))),
401
+ /**
402
+ * The knob is an SVG circle, not a border-radius box.
403
+ *
404
+ * A `<div>` with `border-radius` is only round as long as nothing else
405
+ * controls its box, and something in the host does: the same CSS that is
406
+ * perfectly round in isolation rendered as a rounded rectangle inside the
407
+ * app, at every radius value tried. An SVG circle's shape comes from its
408
+ * geometry, so no `border-radius`, `padding`, or inherited box rule can
409
+ * square it off.
410
+ */
411
+ h('svg', {
412
+ className: 'dmp-slider-thumb',
413
+ viewBox: '0 0 28 28',
414
+ 'aria-hidden': true
415
+ },
416
+ h('circle', {
417
+ // Outer edge = r + stroke/2 = 13.25 + 0.75 = 14 units, and the 28-unit
418
+ // box is drawn at 32px — so the disc's radius is exactly 16px, the same
419
+ // as the rail's end cap. Smaller and the blue cap peeks out as a thin
420
+ // rim around the knob at the first stop; larger and the knob spills
421
+ // past the pill.
422
+ cx: 14,
423
+ cy: 14,
424
+ r: 13.25,
425
+ // Fill and ring come from CSS so the two themes can differ: the dark
426
+ // theme keeps the knob the panel's own grey with a black ring, the light
427
+ // theme flips the fill to white and keeps the black ring.
428
+ fill: 'var(--dmp-thumb-fill, currentColor)',
429
+ stroke: 'var(--dmp-thumb-ring, #000)',
430
+ strokeWidth: 1.5
431
+ }))),
432
+ h('input', {
433
+ type: 'range',
434
+ ref: inputRef,
435
+ className: 'dmp-slider-input',
436
+ min: 0,
437
+ max: Math.max(0, count - 1),
438
+ step: 1,
439
+ // Uncontrolled: the DOM owns the position during a gesture, so a parent
440
+ // render cannot snap it back. `defaultValue` seeds it on mount, and the
441
+ // effect above re-seeds it whenever the level changes from outside.
442
+ defaultValue: incoming,
443
+ disabled: disabled === true || count <= 1,
444
+ 'aria-label': t.effortHeading,
445
+ 'aria-valuetext': activeName,
446
+ // Live feedback only — no Host call, so the thumb tracks at full speed.
447
+ onInput: (event) => {
448
+ const at = Number(event.target.value)
449
+ if (Number.isFinite(at)) setDragging(at)
450
+ },
451
+ // The commit: browsers fire `change` on release (and on every key press).
452
+ onChange: (event) => {
453
+ const at = Number(event.target.value)
454
+ const next = efforts[at]
455
+ setDragging(null)
456
+ if (next !== undefined) onChange(next.id)
457
+ }
458
+ })),
459
+
460
+ h('div', { className: 'dmp-slider-legend' },
461
+ efforts.map((effort, at) => h('button', {
462
+ key: effort.id,
463
+ type: 'button',
464
+ className: `dmp-slider-tick${at === index ? ' dmp-slider-tick-active' : ''}`,
465
+ disabled: disabled === true,
466
+ 'aria-pressed': at === index,
467
+ 'data-edge': count <= 1 ? undefined : at === 0 ? 'start' : at === count - 1 ? 'end' : undefined,
468
+ title: effort.name ?? effort.id,
469
+ style: { left: position(at) },
470
+ onClick: () => onChange(effort.id)
471
+ }, effort.name ?? effort.id))),
472
+ h('p', { className: 'dmp-slider-hint' }, t.effortHint))
473
+ }
563
474
  }
564
475
 
565
476
  //# module: src/ui/styles.js
@@ -567,55 +478,349 @@ function Chevron({ h, open }) {
567
478
  * The picker's stylesheet.
568
479
  *
569
480
  * Host theme tokens only, plus the two composer variables the row sets when its
570
- * controls no longer fit on one line. Every class is prefixed `dmp-` so a copied
481
+ * controls no longer fit on one line. Every class is prefixed dmp- so a copied
571
482
  * host pattern can never collide with the host's own rules.
483
+ *
484
+ * Sizing follows the shipped menu rather than inventing its own scale: the panel
485
+ * is max-content wide (so a short list stays narrow instead of stretching to a
486
+ * fixed width), rows are 34px tall with 5px/7px padding, and the panel pads 4px.
487
+ * Those numbers are what makes the replacement feel like part of the composer
488
+ * instead of a dialog dropped on top of it.
572
489
  */
573
490
  const CSS = `
574
- .dmp-root { position: relative; display: flex; align-items: center; gap: 2px; min-width: 0; }
491
+ .dmp-root { position: relative; display: flex; align-items: center; gap: 6px; min-width: 0; }
575
492
  /* One popover anchor per control: the model list and the level list open above
576
493
  their own button and never overlap each other. */
577
494
  .dmp-control { position: relative; display: flex; align-items: center; min-width: 0; }
578
- .dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: min(360px, 45cqw);
579
- height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: 6px; cursor: pointer;
495
+ /* The model tag keeps a stable box too, for the same reason: switching models must
496
+ not move the level tag beside it. It is capped rather than fixed so a long model
497
+ name still gets room, but the cap is what the layout reserves. */
498
+ .dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0;
499
+ max-width: min(220px, 34cqw); height: 28px; padding: 0 4px 0 8px; border: 0;
500
+ border-radius: var(--dsw-radius-sm, 6px); cursor: pointer;
580
501
  background: transparent; color: var(--dsw-alias-label-secondary);
581
502
  font: inherit; font-size: 13px; line-height: 20px; }
582
- .dmp-trigger-effort { max-width: min(150px, 28cqw); padding: 0 4px 0 6px; font-size: 12px;
583
- color: var(--dsw-alias-label-secondary); }
503
+ /* The level tag has a FIXED width.
504
+ Level names differ in length ("Low" vs "X-High"), so a content-sized tag made
505
+ the whole composer row shift every time the level changed — and pushed the
506
+ model button sideways with it. A fixed box keeps both controls still.
507
+
508
+ The width is sized to the longest name a provider is likely to send: "Medium"
509
+ measures ~50px at 12px/600, and the earlier 74px box only left 44px for text,
510
+ so it rendered as "Medi…". 92px leaves 56px, which also covers "Extreme" and
511
+ "Minimal".
512
+
513
+ The label is CENTRED in the button, which needs the caret out of the flex flow:
514
+ a normal flex child reserves 18px on the right only, so centring the label in
515
+ what was left pushed it ~7px left of the button's true centre. Absolutely
516
+ positioning the caret and giving the button 18px padding on BOTH sides makes
517
+ the label's own box symmetric, so text-align: center lands on the button's
518
+ centre. 18px is exactly the caret's own width (14px) plus its 4px inset, which
519
+ also stops a long label from running underneath it.
520
+ The caret keeps its transform free for the open-state rotation by centring
521
+ with margin-top instead of translateY. */
522
+ .dmp-trigger-effort { position: relative; width: 92px; box-sizing: border-box;
523
+ padding: 0 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
524
+ .dmp-trigger-effort > .dmp-chevron { position: absolute; right: 4px; top: 50%;
525
+ margin-top: -7px; }
526
+ /* The tag names the level; it is not a brightness meter. Taking the ramp colour here
527
+ made "Low" render dim grey and "High" near white, so the control appeared to change
528
+ weight as the level moved. The ramp stays on the track, where it means intensity. */
529
+ .dmp-trigger-effort-text { flex: 1 1 auto; min-width: 0; text-align: center;
530
+ color: var(--dsw-alias-label-primary); font-weight: 600; }
531
+ /* Hover and open share ONE background, and it is the translucent interactive
532
+ token, not the opaque layer-2 surface.
533
+ The layer-2 token resolves to the composer's own surface colour in both themes
534
+ (light white on white, dark 2c2c2e on 2c2c2e), so hovering this tag painted
535
+ nothing at all — the level button was the only control in the row with no
536
+ pointer feedback. The translucent token composes over whatever is behind it
537
+ and matches the model tag beside it. */
538
+ .dmp-trigger-effort:hover:not(:disabled),
539
+ .dmp-trigger-effort[aria-expanded="true"] {
540
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-interactive-bg-active));
541
+ color: var(--dsw-alias-label-primary); }
542
+ .dmp-trigger-effort:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
584
543
  .dmp-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
585
- .dmp-trigger:disabled { color: var(--dsw-alias-state-idle-primary); cursor: default; }
586
- .dmp-trigger-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
544
+ .dmp-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-state-idle-primary)); cursor: default; }
587
545
  .dmp-trigger-text { display: var(--dsh-composer-model-text-display, block);
588
546
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
589
- .dmp-chevron { flex: none; color: var(--dsw-alias-label-secondary); transition: transform .12s; }
547
+ .dmp-chevron { flex: none; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary));
548
+ transition: transform .12s; }
590
549
  .dmp-chevron-open { transform: rotate(180deg); }
591
- .dmp-panel { position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 40;
592
- width: min(420px, 84vw); max-height: 320px; display: flex; flex-direction: column;
593
- border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
594
- background: var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1));
595
- box-shadow: 0 8px 28px rgba(0,0,0,.18); overflow: hidden; }
596
- .dmp-search { flex: none; padding: 8px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
597
- .dmp-search input { width: 100%; box-sizing: border-box; padding: 6px 8px; font: inherit; font-size: 12.5px;
598
- color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2, transparent);
599
- border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; outline: none; }
600
- .dmp-search input:focus-visible { border-color: var(--dsw-alias-brand-primary); }
601
- .dmp-list { flex: 1; overflow-y: auto; padding: 4px 0 6px; }
602
- .dmp-group { padding: 6px 12px 2px; font-size: 11px; letter-spacing: .04em;
603
- color: var(--dsw-alias-label-secondary); }
604
- .dmp-item { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
605
- padding: 6px 12px; border: 0; background: transparent; cursor: pointer; text-align: left;
550
+ /* Content-sized, like the shipped menu: a two-model list must not be as wide as a
551
+ twenty-model one. The caps only exist so a long model name cannot run off the
552
+ viewport. */
553
+ .dmp-panel { position: absolute; bottom: calc(100% + 6px + var(--dmp-notice-lift, 0px)); z-index: 1000;
554
+ width: max-content; min-width: min(240px, 100vw - 32px); max-width: min(360px, 92vw);
555
+ max-height: min(320px, 60vh); display: flex; flex-direction: column; padding: 4px;
556
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-lg, 10px);
557
+ background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
558
+ box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv3));
559
+ color: var(--dsw-alias-label-primary); overflow: hidden; }
560
+ .dmp-search { flex: none; padding: 0 0 4px; }
561
+ .dmp-search input { width: 100%; box-sizing: border-box; padding: 5px 7px; font: inherit; font-size: 12.5px;
562
+ color: var(--dsw-alias-label-primary); background: transparent;
563
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px); outline: none; }
564
+ .dmp-search input:focus-visible { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
565
+ /* The scroll container.
566
+ *
567
+ * The scrollbar is NOT styled here. The host owns scrollbar appearance through two
568
+ * CSS variables that its own stylesheets consume, so setting them on the panel is
569
+ * the whole job: the shell then paints a thin, rounded, theme-aware bar that matches
570
+ * every other DSH popover. Writing -webkit-scrollbar rules as well would override
571
+ * the host's treatment and is what made this panel look different from the rest of
572
+ * the app.
573
+ *
574
+ * This mirrors the shipped picker and the thinking-slider plugin, which both do
575
+ * exactly this much. */
576
+ .dmp-list { flex: 1; overflow-y: auto; min-height: 0;
577
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
578
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); }
579
+ .dmp-group { padding: 6px 7px 2px; font-size: 11px; letter-spacing: .04em;
580
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
581
+ .dmp-item { display: flex; align-items: center; width: 100%; box-sizing: border-box;
582
+ min-height: 34px; padding: 5px 7px; border: 0; border-radius: var(--dsw-radius-md, 8px);
583
+ background: transparent; cursor: pointer; text-align: left;
606
584
  color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
607
585
  .dmp-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
608
586
  .dmp-item-current { color: var(--dsw-alias-brand-primary); font-weight: 600; }
609
587
  .dmp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
610
- .dmp-item-note { margin-left: auto; padding-left: 8px; font-size: 11px;
611
- color: var(--dsw-alias-label-secondary); }
612
- /* The level list holds short labels, so it stays narrow and tight. */
613
- .dmp-panel-narrow { width: min(220px, 60vw); max-height: 260px; }
614
- .dmp-note { padding: 10px 12px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
588
+ .dmp-note { padding: 8px 7px; font-size: 12px; line-height: 18px;
589
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
615
590
  .dmp-note-error { color: var(--dsw-alias-state-error-primary); }
616
591
  .dmp-retry { margin-top: 6px; padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l2);
617
- border-radius: 6px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
618
- `
592
+ border-radius: var(--dsw-radius-sm, 6px); background: transparent; color: inherit;
593
+ font: inherit; font-size: 12px; cursor: pointer; }
594
+
595
+ /* The padding is what keeps the heading and the hint off the panel edge; the values
596
+ are generous because this panel has no menu rows to create natural breathing room. */
597
+ .dmp-panel-slider { width: max-content; box-sizing: border-box;
598
+ min-width: min(322px, 100vw - 32px); max-width: min(374px, 100vw - 32px);
599
+ padding: 17px 19px 15px; border-radius: var(--dsw-radius-lg, 12px); }
600
+ /* The type stack, in two parts.
601
+ *
602
+ * The level names are Latin ("Low", "Medium", "X-High") and get the host's own
603
+ * brand face, Montserrat, which the theme plugin already ships at 300/400/500 —
604
+ * so this costs no download and matches the host's own headings. The CJK copy
605
+ * ("思考强度", the hint line) has no glyphs in Montserrat, so the stack falls
606
+ * through to the platform UI face, which is what the host uses for body text.
607
+ *
608
+ * Nothing here sets a size: that stays with each element, so a host font-size
609
+ * change still reaches this control. The slider inherits this from the panel it is
610
+ * always rendered inside, so it is not named here. */
611
+ .dmp-panel {
612
+ font-family: Montserrat, var(--dsw-font-family-sans, system-ui, -apple-system,
613
+ 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', sans-serif); }
614
+ .dmp-slider { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
615
+ .dmp-slider-head { display: flex; align-items: center; gap: 14px; margin-bottom: 5px; }
616
+ /* The heading is vector art, not text: see CaptionMark.js for why. It only needs a
617
+ colour — which it takes from the theme token below through currentColor — and a
618
+ display box that sits it on the same line as the value pill. */
619
+ .dmp-slider-caption { display: flex; align-items: center;
620
+ color: var(--dsw-alias-label-primary); }
621
+ .dmp-caption-mark { display: block; width: auto; }
622
+ /* The Latin heading, used wherever the vector mark would be unreadable. It takes
623
+ the heading's own size so the two stay interchangeable on the same line. */
624
+ .dmp-caption-text { display: block; font-size: 13px; font-weight: 600; line-height: 16px;
625
+ letter-spacing: .01em; white-space: nowrap; }
626
+ /* The value pill is sized close to the heading beside it, so the two read as one
627
+ line: the earlier 19px box with 11.5px type made the current level look like a
628
+ footnote next to the title. 26px / 14px keeps the pair balanced and still fits
629
+ a long level name. */
630
+ .dmp-slider-value { margin-left: auto; display: inline-flex; align-items: center;
631
+ height: 22px; box-sizing: border-box; padding: 0 8px; border-radius: 8px;
632
+ font-size: 12px; line-height: 1; font-weight: 600; white-space: nowrap;
633
+ letter-spacing: .01em;
634
+ /* The pill is a label, not a rank meter: it must read the SAME at every level.
635
+ It used to take the level's ramp colour, so Low rendered dim grey and Max near
636
+ white — the same component looking like two different things depending on the
637
+ setting. The ramp colour stays on the track, where it encodes intensity. */
638
+ color: var(--dsw-alias-label-primary);
639
+ background: var(--dsw-alias-interactive-bg-hover); }
640
+ /* Reasoning effort: the slider.
641
+ ---------------------------------------------------------------------------
642
+ A thick pill-shaped track with a solid accent fill and a disc riding on it. The
643
+ thick track is what makes the control legible at a glance — a hairline rail with
644
+ a small dot reads as a status indicator, not something you can drag.
645
+
646
+ The fill and the active label take the host's business-primary token — the same
647
+ accent DSH uses for its own interactive highlights — so the control recolours
648
+ with the theme instead of carrying a blue that only suits one of them. */
649
+ /* The knob is the rail's full height (32px). The two stop offsets are deliberately
650
+ UNEQUAL:
651
+ --dmp-stop-left 32px the first stop sits a full knob diameter in from the
652
+ left cap, so the knob stops short of that end;
653
+ --dmp-stop-right 16px the last stop is one knob RADIUS in from the right cap,
654
+ so the knob reaches that end flush.
655
+ That asymmetry is the requested behaviour, not an oversight. The dots, the
656
+ labels, the fill and the native input's travel all derive from these two numbers,
657
+ which is why they are variables rather than four copies of the same value. */
658
+ .dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
659
+ --dmp-stop-left: 28px; --dmp-stop-right: 14px; }
660
+ /* Radius is a LITERAL 17px on a 32px-tall rail — deliberately ONE more than half the
661
+ height, so the ends curve a touch harder than a true semicircle and read as round
662
+ rather than as pillowed caps. Anything below half looks square; this is the
663
+ user-tuned value.
664
+ The two numbers are tied: change the height, move the radius with it.
665
+
666
+ clip-path does the actual shaping. A border-radius of 16px on this 32px box was
667
+ measured painting an ~10px corner in the host, so the ends kept reading as a
668
+ rounded rectangle no matter what the declaration said. clip-path: inset(... round
669
+ 17px) is a different property: it clips the element AND its children to the same
670
+ shape, so neither the track nor the fill can be squared off by whatever was
671
+ winning on border-radius. border-radius stays as the fallback. */
672
+ .dmp-slider-rail { position: relative; height: var(--dmp-rail-height);
673
+ margin: 12px 0 4px; border-radius: 15px; flex: none;
674
+ clip-path: inset(0 round 15px);
675
+ background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
676
+ /* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
677
+ stop and 100% the last — the knob's travel, the dots, the labels and the fill all
678
+ read from this one box. */
679
+ .dmp-slider-track { position: absolute; top: 0; bottom: 0;
680
+ left: var(--dmp-stop-left); right: var(--dmp-stop-right); }
681
+ /* The fill starts at the RAIL's left edge and ends on the knob's centre line.
682
+
683
+ Starting it at the stop axis left a grey stub at the left cap, and the rail's
684
+ clip gives that end its shape, so the run is pulled back to the rail's edge.
685
+
686
+ It carries NO radius of its own: it is a plain rectangle clipped by the rail,
687
+ which is what makes its left cap identical to the track's. */
688
+ .dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
689
+ left: calc(var(--dmp-stop-left) * -1);
690
+ width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
691
+ background: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
692
+ z-index: 1; transition: width .15s ease-out; pointer-events: none; }
693
+ .dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
694
+ pointer-events: none; z-index: 2; }
695
+ /* Stops stay SMALL and GREY: they mark the stations, the knob is what you grab.
696
+ The reached stop used to take the panel's surface colour, which is near-black in
697
+ the dark theme — a black pinhole punched into the blue. Both states now use the
698
+ label ramp, so a dot reads as grey on the track and as light grey on the fill. */
699
+ .dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
700
+ border-radius: 50%; transform: translate(-50%, -50%);
701
+ background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
702
+ transition: opacity .15s ease-out, background .15s ease-out; }
703
+ /* A reached stop sits on the coloured fill, so it lightens instead of going dark. */
704
+ .dmp-slider-dot-lit { background: var(--dsw-alias-label-secondary, currentColor); opacity: .9; }
705
+ /* The knob fills the rail's height and is drawn as an SVG, so no inherited
706
+ border-radius, padding or box-sizing rule can square it off. Its size comes from
707
+ --dmp-thumb-size; the stop offsets and the input's travel are derived from the
708
+ same variable so the three cannot drift apart. */
709
+ .dmp-slider-thumb { position: absolute; top: 50%; left: var(--dmp-effort-at);
710
+ width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
711
+ margin-left: calc(var(--dmp-thumb-size) / -2);
712
+ margin-top: calc(var(--dmp-thumb-size) / -2);
713
+ display: block; overflow: visible; pointer-events: none; z-index: 5;
714
+ /* The knob takes the panel's grey in dark and pure white in light, with a black
715
+ ring in both. The two values swing with the host stylesheet's own media query,
716
+ so no JS theme detection is involved. */
717
+ --dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
718
+ --dmp-thumb-ring: #000;
719
+ filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
720
+ transition: left .15s ease-out, transform .12s ease; }
721
+ .dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
722
+ .dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
723
+ box-shadow: 0 0 0 3px color-mix(in srgb,
724
+ var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 35%, transparent); }
725
+ /* The range input spans from the first stop's reach to the rail's right edge, and
726
+ its native thumb is the knob's size. That makes its invisible thumb's centre
727
+ travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
728
+ knob is drawn on. Spanning the full rail instead would start the travel one
729
+ radius short of the first stop, and the pointer would disagree with the knob. */
730
+ .dmp-slider-input { position: absolute; z-index: 6; top: 0;
731
+ left: calc(var(--dmp-stop-left) - var(--dmp-thumb-size) / 2);
732
+ width: calc(100% - var(--dmp-stop-left) + var(--dmp-thumb-size) / 2);
733
+ height: var(--dmp-rail-height); margin: 0; cursor: pointer;
734
+ -webkit-appearance: none; appearance: none; background: transparent;
735
+ /* Two-level invisibility: opacity alone leaves the native thumb able to paint
736
+ through on some engines, which reads as a stray rectangle riding the track. */
737
+ opacity: 0; color: transparent; border: 0; }
738
+ .dmp-slider-input::-webkit-slider-runnable-track { background: transparent; border: 0; }
739
+ /* The native thumb is sized to the painted one so pointer travel matches, then made
740
+ fully transparent: leaving it visible is what produced a square riding the track. */
741
+ .dmp-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
742
+ width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
743
+ background: transparent; border: 0; }
744
+ .dmp-slider-input::-moz-range-thumb { width: var(--dmp-thumb-size); height: var(--dmp-thumb-size); border: 0; }
745
+ .dmp-slider-input:disabled { cursor: default; }
746
+ .dmp-slider[aria-disabled="true"] { opacity: .5; }
747
+ /* The legend shares the track's inset so a label sits under the stop it names. */
748
+ .dmp-slider-legend { position: relative; height: 16px;
749
+ margin: 0 var(--dmp-stop-right) 0 var(--dmp-stop-left); }
750
+ /* The level labels are Latin, so they take Montserrat explicitly — inheriting from
751
+ the panel would pull the platform face back in. */
752
+ .dmp-slider-tick { position: absolute; top: 0; transform: translateX(-50%);
753
+ padding: 1px 4px; border: 0; border-radius: 6px; background: transparent;
754
+ /* One brightness for every unselected label: the tertiary token sat too close to
755
+ the panel background, so the secondary token lifts all of them equally. */
756
+ color: var(--dsw-alias-label-secondary);
757
+ /* 12px, up from 11px: at 11 the stop names sat visibly below the heading's weight
758
+ and were hard to hit by eye under a 32px rail. */
759
+ font-size: 11px; line-height: 15px;
760
+ font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
761
+ white-space: nowrap; cursor: pointer;
762
+ max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
763
+ .dmp-slider-tick[data-edge="start"] { transform: none; margin-left: -4px; }
764
+ .dmp-slider-tick[data-edge="end"] { transform: translateX(-100%); margin-left: 4px; }
765
+ .dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
766
+ background: var(--dsw-alias-interactive-bg-hover); }
767
+ .dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
768
+ .dmp-slider-tick:disabled { cursor: default; }
769
+ /* The active label is marked by COLOUR only, not weight.
770
+ Bolding it made the selected label measurably wider and taller-looking than its
771
+ neighbours, so the labels appeared to change size as the level moved. Keeping one
772
+ weight keeps every label the same box; the accent colour carries the selection. */
773
+ .dmp-slider-tick-active {
774
+ color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
775
+ .dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
776
+ border-top: 1px solid var(--dsw-alias-border-l1);
777
+ font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
778
+ /* Light surfaces need a softer knob: a black ring reads as a hard edge on a
779
+ near-white panel, so the light theme pulls the ring to the host's border colour
780
+ and keeps the fill pure white. The two values swing with the host stylesheet's
781
+ own media query, so no JS theme detection is involved. */
782
+ @media (prefers-color-scheme: light) {
783
+ .dmp-slider-thumb {
784
+ --dmp-thumb-fill: var(--dsw-alias-bg-base);
785
+ --dmp-thumb-ring: var(--dsw-alias-border-l2);
786
+ }
787
+ }
788
+ @media (prefers-reduced-motion: reduce) {
789
+ .dmp-slider-fill, .dmp-slider-thumb { transition: none; }
790
+ }
791
+ /* ---------------------------------------------------------------------------
792
+ Rejected-selection notice
793
+ ---------------------------------------------------------------------------
794
+ A failed switch is reported ABOVE the panels, never inside them: the list and
795
+ the slider are for choosing, and a raw "code: message" dumped among the rows
796
+ both crowds the list and scrolls away. Collapsed it is one quiet line; the
797
+ detail is opt-in.
798
+
799
+ It is absolutely positioned above the row so it takes no width from the
800
+ composer, and --dmp-notice-lift (measured in ModelPicker) is what raises an
801
+ open panel over its measured height instead of on top of it. */
802
+ .dmp-notice { position: absolute; bottom: calc(100% + 6px); left: 0; right: auto;
803
+ z-index: 1100; display: flex; flex-direction: column; align-items: flex-start;
804
+ width: max-content; max-width: min(360px, 92vw); box-sizing: border-box;
805
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px);
806
+ background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
807
+ box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv2));
808
+ overflow: hidden; }
809
+ .dmp-notice-toggle { display: flex; align-items: center; gap: 6px; width: 100%;
810
+ box-sizing: border-box; padding: 6px 8px; border: 0; background: transparent;
811
+ color: var(--dsw-alias-state-error-primary); font: inherit; font-size: 12px;
812
+ line-height: 18px; text-align: left; cursor: pointer; }
813
+ .dmp-notice-toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
814
+ .dmp-notice-toggle:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: -2px; }
815
+ .dmp-notice-toggle .dmp-chevron { margin-left: auto; }
816
+ .dmp-notice-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
817
+ /* The raw provider error: monospaced so a "code: message" pair is readable, and
818
+ selectable so it can be copied into a bug report. */
819
+ .dmp-notice-detail { width: 100%; box-sizing: border-box; padding: 6px 8px;
820
+ border-top: 1px solid var(--dsw-alias-border-l1);
821
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
822
+ font-size: 11.5px; line-height: 16px; white-space: pre-wrap; word-break: break-word;
823
+ color: var(--dsw-alias-label-secondary); user-select: text; }`
619
824
 
620
825
  //# module: src/ui/dict.js
621
826
  /**
@@ -624,35 +829,36 @@ const CSS = `
624
829
  * Kept as a plain object rather than the Client locale service because the
625
830
  * strings are this component's own copy, not a namespace other plugins
626
831
  * translate. Adding a language is one more entry here.
832
+ *
833
+ * Only keys the component actually reads belong here: an entry nobody looks up
834
+ * is a string that silently rots, and a reviewer cannot tell it is dead.
627
835
  */
628
836
  const DICT = {
629
837
  'zh-CN': {
630
838
  placeholder: '搜索模型',
631
839
  empty: '没有匹配的模型',
632
840
  loading: '正在读取模型列表…',
633
- failed: '读取模型列表失败',
634
841
  retry: '重试',
635
842
  noCatalog: '当前没有可用模型',
636
843
  trigger: (name) => `${name}(点此切换模型)`,
637
844
  effortTitle: (name) => `思考档位:${name}(点此调整)`,
845
+ effortHeading: '思考强度',
846
+ effortHint: '更高的强度通常需要更多思考时间。',
638
847
  switching: '切换中…',
639
- switchFailed: '切换失败',
640
- providerDefault: '默认',
641
- groupFailed: (name) => `${name} 加载失败`
848
+ switchFailed: '切换失败'
642
849
  },
643
850
  en: {
644
851
  placeholder: 'Search models',
645
852
  empty: 'No matching model',
646
853
  loading: 'Loading models…',
647
- failed: 'Could not load the model list',
648
854
  retry: 'Retry',
649
855
  noCatalog: 'No model is currently available',
650
856
  trigger: (name) => `${name} (click to switch model)`,
651
857
  effortTitle: (name) => `Reasoning level: ${name} (click to change)`,
858
+ effortHeading: 'Reasoning effort',
859
+ effortHint: 'Higher effort usually takes more time to think.',
652
860
  switching: 'Switching…',
653
- switchFailed: 'Switch failed',
654
- providerDefault: 'Default',
655
- groupFailed: (name) => `${name} failed to load`
861
+ switchFailed: 'Switch failed'
656
862
  }
657
863
  }
658
864
 
@@ -671,7 +877,27 @@ function dictionaryFor(localeId) {
671
877
 
672
878
 
673
879
 
674
- const LABEL_SLOT = 'conversation.input.model.label'
880
+
881
+
882
+ /**
883
+ * Turn a failed `select` result into the two strings the notice shows.
884
+ *
885
+ * `ModelDirectory.select` resolves to `{ ok: false, error: { code, message } }`
886
+ * on a rejected selection. The summary is the short, always-visible line; the
887
+ * detail is what expanding the notice reveals, and it is the only place the
888
+ * provider's raw error text appears — the panels themselves stay free of it.
889
+ *
890
+ * @param result - the value `select` resolved to, possibly undefined.
891
+ * @param t - the active dictionary.
892
+ * @returns `{ summary, detail }`.
893
+ */
894
+ function failureOf(result, t) {
895
+ const error = result?.error
896
+ if (error === undefined || error === null) return { summary: t.switchFailed, detail: '' }
897
+ const code = typeof error.code === 'string' ? error.code : ''
898
+ const message = typeof error.message === 'string' ? error.message : String(error)
899
+ return { summary: t.switchFailed, detail: code.length > 0 ? `${code}: ${message}` : message }
900
+ }
675
901
 
676
902
  /**
677
903
  * The composer's model seat: a searchable, provider-grouped picker plus a
@@ -681,59 +907,114 @@ const LABEL_SLOT = 'conversation.input.model.label'
681
907
  * `current` (the effective selection, projection or default), `routable`
682
908
  * (whether the Host would still accept it), `pending` (the selection being
683
909
  * settled), `failures` (provider groups that failed to load) and `error`.
684
- * Deriving those in the component is what made an in-flight switch invisible and
685
- * a broken provider group silently absent.
686
910
  *
687
- * @param props - slot props: `locked`, the session id, `renderSlot` (when the
688
- * entry declared children), plus what `inject` supplied.
689
- * @returns the rendered control, or null when the session cannot change models.
911
+ * @param React - the React runtime.
912
+ * @returns the slot component.
690
913
  */
691
914
  function createModelPicker(React) {
692
915
  const h = React.createElement
916
+ const EffortSlider = createEffortSlider(React)
693
917
  const { useCallback, useEffect, useMemo, useRef, useState } = React
694
918
 
919
+ /**
920
+ * Subscribe to any `{ getSnapshot, subscribe }` source.
921
+ *
922
+ * Both sources this component reads — the session directory and the locale
923
+ * service — expose exactly that pair, so one hook covers them. It lives here
924
+ * rather than in a shared module because it needs the component's own hooks,
925
+ * which are bound to this factory's React instance.
926
+ *
927
+ * @param source - the observable source, or undefined while it is not resolved.
928
+ * @returns the current snapshot.
929
+ */
930
+ const useSnapshot = (source) => {
931
+ const [value, setValue] = useState(() => source?.getSnapshot?.())
932
+ useEffect(() => {
933
+ if (source === undefined || source === null) return undefined
934
+ const update = () => setValue(source.getSnapshot())
935
+ update()
936
+ return source.subscribe(update)
937
+ }, [source])
938
+ return value
939
+ }
940
+
695
941
  return function ModelPicker(props) {
696
- const { sessionId, locked, renderSlot, directory, catalog: catalogFace, available } = props
942
+ /**
943
+ * The props bundle the shipped registration hands its own component.
944
+ *
945
+ * `directory` is the session directory's STORE (already subscribed-ready),
946
+ * `load` asks it to refresh, `select` submits a triple, and `available` says
947
+ * whether this session may change models at all. Nothing here reaches behind
948
+ * that bundle: the data layer is the shipped one, this file is the view.
949
+ */
950
+ const { locked, directory, available, load, select: submit } = props
697
951
 
698
- const localeId = typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
952
+ /**
953
+ * The active language, followed live.
954
+ *
955
+ * `localeSource` is the locale service itself (`getSnapshot` / `subscribe`),
956
+ * so a language switch made while the composer is on screen re-renders with
957
+ * the new dictionary. `localeId` is the value resolved when the seat was
958
+ * registered, used only until the first snapshot arrives.
959
+ */
960
+ const localeState = useSnapshot(props.localeSource)
961
+ const localeId = typeof localeState?.active === 'string' && localeState.active.length > 0
962
+ ? localeState.active
963
+ : typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
699
964
  const t = dictionaryFor(localeId)
965
+ // The panel heading is vector art drawn from CJK outlines, so it only reads
966
+ // as words in Chinese; every other language prints the plain string.
967
+ const glyphCaption = String(localeId).toLowerCase().startsWith('zh')
700
968
 
701
969
  // One panel at a time: the model list and the effort list are separate
702
970
  // controls, and opening either closes the other.
703
971
  const [panel, setPanel] = useState(null)
704
972
  const [query, setQuery] = useState('')
973
+ // The level the thumb should sit on right now, before the Host confirms.
974
+ // Null means "follow the directory".
975
+ const [draftEffort, setDraftEffort] = useState(null)
976
+ // A rejected selection. Kept out of the panels on purpose: the notice above
977
+ // them is collapsed until the user asks for the raw error.
978
+ const [failure, setFailure] = useState(null)
979
+ const [noticeOpen, setNoticeOpen] = useState(false)
980
+ const [noticeLift, setNoticeLift] = useState(0)
705
981
  const rootRef = useRef(null)
982
+ const noticeRef = useRef(null)
983
+ // Whether the most recent action was a load or a selection. `state.error`
984
+ // carries both, and only a LOAD failure belongs inside the model panel.
985
+ const lastActionRef = useRef('load')
706
986
 
707
- const state = readSnapshot(useState, useEffect, directory?.store) ?? {}
708
- // `catalogFace` is the catalogue controller, not a store: its snapshot source
709
- // is `store`. Passing the controller itself made the hook call a missing
710
- // `getSnapshot` on first render.
711
- const catalogState = readSnapshot(useState, useEffect, catalogFace?.store)
987
+ const state = useSnapshot(directory) ?? {}
712
988
  const groups = Array.isArray(state.groups) ? state.groups : []
713
989
  const failures = Array.isArray(state.failures) ? state.failures : []
714
990
  const selection = state.current ?? null
715
991
  const pending = state.pending ?? null
716
- const busy = pending !== null || state.status === 'selecting'
992
+ /**
993
+ * Distinguish a MODEL change from a LEVEL change.
994
+ *
995
+ * The directory reports one `selecting` status for both, and `pending` holds
996
+ * whichever triple is in flight. They are not the same event to the user: a
997
+ * level change is a dial, dragged back and forth, and each stop submits. So a
998
+ * pending selection is treated as a model change ONLY when it names a
999
+ * different model than the one in effect — otherwise every slider stop made
1000
+ * the model trigger print "Switching…" and go dim, which read as the model
1001
+ * button flickering on its own.
1002
+ */
1003
+ const modelKeyOf = (value) => value === null ? null : `${value.provider ?? ''}:${value.model ?? ''}`
1004
+ const swappingModel = pending !== null && modelKeyOf(pending) !== modelKeyOf(selection)
1005
+ const busy = swappingModel
1006
+
1007
+ const doLoad = useCallback(() => {
1008
+ lastActionRef.current = 'load'
1009
+ // Ask the directory to load. It is idempotent: the shipped controller
1010
+ // shares one in-flight request and re-projects when it settles.
1011
+ if (typeof load === 'function') load()
1012
+ }, [load])
717
1013
 
718
1014
  useEffect(() => {
719
- /**
720
- * Load the directory, not just a catalogue.
721
- *
722
- * In reuse mode `directory` is the shipped one and `catalogFace` is this
723
- * plugin's own catalogue — loading the latter alone left the shipped
724
- * directory's catalogue untouched, so its `groups` never advanced past
725
- * whatever it held when the seat first mounted. `directory.load()` asks the
726
- * object that actually feeds `state.groups`; its own `load` is idempotent
727
- * and internally refreshes the catalogue it reads.
728
- */
729
- if (typeof directory?.load === 'function') {
730
- const outcome = directory.load()
731
- if (outcome !== undefined && typeof outcome.then === 'function') outcome.catch(() => {})
732
- } else if (catalogFace !== undefined && catalogFace !== null) {
733
- catalogFace.load()
734
- }
1015
+ doLoad()
735
1016
  return undefined
736
- }, [directory, catalogFace])
1017
+ }, [doLoad])
737
1018
 
738
1019
  useEffect(() => {
739
1020
  if (panel === null) return undefined
@@ -749,38 +1030,116 @@ function createModelPicker(React) {
749
1030
  }
750
1031
  }, [panel])
751
1032
 
1033
+ // Closing a panel also clears the transient state it carried: the search
1034
+ // text (so reopening shows the full list) and the failure notice.
1035
+ useEffect(() => {
1036
+ if (panel !== null) return
1037
+ setQuery('')
1038
+ setFailure(null)
1039
+ setNoticeOpen(false)
1040
+ }, [panel])
1041
+
1042
+ /**
1043
+ * Push a failure into the notice and collapse it again.
1044
+ *
1045
+ * @param result - the failed `select` result.
1046
+ */
1047
+ const showFailure = useCallback((result) => {
1048
+ setFailure(failureOf(result, t))
1049
+ setNoticeOpen(false)
1050
+ }, [t])
1051
+
752
1052
  /**
753
1053
  * Choose a model, carrying the adapter's default effort when the caller did
754
- * not name one — the shipped picker sends the same triple, so a model whose
755
- * adapter defaults to a level records that level rather than an unset field.
1054
+ * not name one — the shipped picker sends the same triple.
1055
+ *
1056
+ * Returns the directory's own result unchanged: `{ ok: true }` or
1057
+ * `{ ok: false, error }`. Anything else would make a successful pick look
1058
+ * like a failure and leave the panel open.
756
1059
  */
757
1060
  const select = useCallback(async (group, model, reasoningEffort) => {
758
- if (directory === undefined || directory === null) return
1061
+ if (typeof submit !== 'function') return undefined
1062
+ lastActionRef.current = 'select'
759
1063
  const effort = reasoningEffort ?? model?.reasoning?.defaultEffort
760
- const result = await directory.select({
1064
+ return submit({
761
1065
  provider: group.id,
762
1066
  model: model.id,
763
1067
  ...effort === undefined ? {} : { reasoningEffort: effort }
764
1068
  })
765
- if (result?.ok !== true) return
766
- setPanel(null)
767
- }, [directory])
768
-
769
- const needle = query.trim().toLowerCase()
770
- const filtered = useMemo(() => {
771
- const match = (model) => needle.length === 0 ||
772
- String(model.name ?? model.id).toLowerCase().includes(needle) ||
773
- String(model.id).toLowerCase().includes(needle)
774
- return groups
775
- .map((group) => ({ group, models: (group.models ?? []).filter(match) }))
776
- .filter((entry) => entry.models.length > 0)
777
- }, [groups, needle])
1069
+ }, [submit])
1070
+
1071
+ /**
1072
+ * Choosing a MODEL closes the panel on success.
1073
+ *
1074
+ * A rejected selection keeps it open and raises the notice instead, so the
1075
+ * user can pick another model without reopening the list.
1076
+ */
1077
+ const chooseModel = useCallback(async (group, model, reasoningEffort) => {
1078
+ const result = await select(group, model, reasoningEffort)
1079
+ if (result?.ok === true) {
1080
+ setFailure(null)
1081
+ setPanel(null)
1082
+ return
1083
+ }
1084
+ showFailure(result)
1085
+ }, [select, showFailure])
1086
+
1087
+ const queryText = query.trim()
1088
+ /**
1089
+ * The model list, ranked exactly like the shipped menus.
1090
+ *
1091
+ * `rankByName` is the host's own fuzzy matcher: an ordered subsequence of
1092
+ * the name (or of the model id, supplied as the second search key), prefix
1093
+ * hits first. A plain substring test could not find `gpt61` in
1094
+ * `GPT-6.1-Sol`, which is most of the point of having a search box.
1095
+ */
1096
+ const filtered = useMemo(() => groups
1097
+ .map((group) => ({
1098
+ group,
1099
+ models: rankByName(
1100
+ (group.models ?? []).map((model) => ({
1101
+ ...model,
1102
+ label: typeof model.label === 'string' ? model.label : model.id
1103
+ })),
1104
+ queryText
1105
+ )
1106
+ }))
1107
+ .filter((entry) => entry.models.length > 0), [groups, queryText])
778
1108
 
779
1109
  const current = selection === null ? null : groups
780
1110
  .find((group) => group.id === selection.provider)?.models
781
1111
  ?.find((model) => model.id === selection.model)
782
1112
  const currentGroup = selection === null ? null : groups.find((group) => group.id === selection.provider) ?? null
783
1113
 
1114
+ /**
1115
+ * A level change keeps the panel open, and moves the thumb IMMEDIATELY.
1116
+ *
1117
+ * Two things were wrong before:
1118
+ *
1119
+ * - Closing the panel on success made it vanish mid-drag, so the animation
1120
+ * never played — a level is a dial, not a commitment.
1121
+ * - The thumb was positioned from the directory's `current`, which only
1122
+ * updates after the Host answers. During a drag that means the thumb is
1123
+ * dragged back to the old stop on every render — it looks stuck to the
1124
+ * start and refuses to follow the pointer.
1125
+ *
1126
+ * `draftEffort` is the optimistic stop: set it on every move so the thumb
1127
+ * tracks the pointer, and clear it once the Host confirms so the directory
1128
+ * becomes the source of truth again. If the RPC fails the draft is dropped,
1129
+ * the thumb springs back to the real value, and the notice says why.
1130
+ */
1131
+ const chooseEffort = useCallback(async (effortId) => {
1132
+ if (currentGroup === null || current === null) return
1133
+ setDraftEffort(effortId)
1134
+ const result = await select(currentGroup, current, effortId)
1135
+ if (result?.ok === true) {
1136
+ setFailure(null)
1137
+ return
1138
+ }
1139
+ setDraftEffort(null)
1140
+ showFailure(result)
1141
+ }, [select, currentGroup, current, showFailure])
1142
+
784
1143
  /**
785
1144
  * What the trigger says, in the shipped picker's precedence.
786
1145
  *
@@ -802,25 +1161,79 @@ function createModelPicker(React) {
802
1161
  * will use, so that is what the control shows rather than a blank.
803
1162
  */
804
1163
  const efforts = Array.isArray(current?.reasoning?.efforts) ? current.reasoning.efforts : []
805
- const activeEffortId = selection?.reasoningEffort ?? current?.reasoning?.defaultEffort
1164
+ /**
1165
+ * The level to show, and why it is NOT simply `selection.reasoningEffort`.
1166
+ *
1167
+ * `selection.reasoningEffort` belongs to the SESSION, not to the model. When
1168
+ * the user switches to a model they have never used, that field still carries
1169
+ * the previous model's level — `xhigh` while the new model only declares
1170
+ * `low/high`, or `high` for both, where inheriting it silently starts the new
1171
+ * model at a level the user never chose for it.
1172
+ *
1173
+ * The rule is therefore: a level is only "current" for a model that has
1174
+ * already been used in this session. On a model switch the adapter's declared
1175
+ * default takes over, which is what the Host would use anyway. `modelKey`
1176
+ * tracks which model the level belongs to, so a switch is detectable.
1177
+ */
1178
+ const declares = (id) => id !== undefined && id !== null && efforts.some((effort) => effort.id === id)
1179
+ const modelKey = current === null ? null : `${selection?.provider ?? ''}:${selection?.model ?? ''}`
1180
+ const seenModelRef = useRef(null)
1181
+ const switchedModel = seenModelRef.current !== null && seenModelRef.current !== modelKey
1182
+ const inheritedEffortId = switchedModel ? undefined : selection?.reasoningEffort
1183
+ /**
1184
+ * The level in effect, resolved through three fallbacks.
1185
+ *
1186
+ * A provider may declare `reasoningEfforts` without a default — the
1187
+ * `openai-responses` models in this profile do exactly that (five levels, no
1188
+ * default). Taking `defaultEffort` verbatim then left `activeEffortId`
1189
+ * undefined, so the tag rendered with an empty label even though the slider
1190
+ * had five stops. The first declared level is the right fallback: it is what
1191
+ * an unspecified request uses.
1192
+ */
1193
+ const confirmedEffortId = declares(inheritedEffortId)
1194
+ ? inheritedEffortId
1195
+ : declares(current?.reasoning?.defaultEffort)
1196
+ ? current.reasoning.defaultEffort
1197
+ : efforts[0]?.id
1198
+ useEffect(() => { seenModelRef.current = modelKey }, [modelKey])
1199
+ const activeEffortId = draftEffort !== null && declares(draftEffort)
1200
+ ? draftEffort
1201
+ : confirmedEffortId
806
1202
  const activeEffort = efforts.find((effort) => effort.id === activeEffortId) ?? null
807
- const effortName = (effort) => effort?.name ?? effort?.id ?? String(activeEffortId ?? '')
1203
+
1204
+ /**
1205
+ * What the trigger prints for the current level.
1206
+ *
1207
+ * The trigger is only rendered when the model declares levels, so a resolved
1208
+ * name always exists by then; the id is a fallback for a level whose entry has
1209
+ * no display name, and an empty string is the last resort.
1210
+ */
1211
+ const effortLabel = activeEffort?.name ?? activeEffort?.id ?? String(activeEffortId ?? '')
1212
+
1213
+ /**
1214
+ * Drop the draft once the Host agrees with it.
1215
+ *
1216
+ * Until the directory catches up the draft IS the truth, so it must not be
1217
+ * cleared earlier or the thumb would jump back for one frame. When the
1218
+ * confirmed value finally matches, the draft has done its job.
1219
+ */
1220
+ useEffect(() => {
1221
+ if (draftEffort !== null && confirmedEffortId === draftEffort) setDraftEffort(null)
1222
+ }, [draftEffort, confirmedEffortId])
808
1223
 
809
1224
  /**
810
1225
  * The label, in the shipped picker's addressable wrapper.
811
1226
  *
812
- * The `data-provider-id` / `data-model-id` span is kept because CSS that
813
- * targets one model by id still works. `renderSlot` is probed rather than
814
- * assumed: the renderer hands an entry that binding only for children the
815
- * entry declared itself, and this one deliberately declares none (the shipped
816
- * occupant owns the declaration), so the plain name is what actually renders.
1227
+ * The `data-provider-id` / `data-model-id` span is kept because host CSS that
1228
+ * targets one model by id still works. It is deliberately plain text: this entry
1229
+ * does not declare the `conversation.input.model.label` child, so the renderer
1230
+ * hands it no `renderSlot` and provider badges cannot mount here (see AGENTS.md,
1231
+ * invariant 2 — declaring that child breaks the whole registration).
817
1232
  */
818
- const renderLabel = (group, model, placement, selected) => h('span', {
1233
+ const renderLabel = (group, model) => h('span', {
819
1234
  'data-provider-id': group?.id,
820
1235
  'data-model-id': model?.id
821
- }, renderSlot === undefined
822
- ? model?.name
823
- : renderSlot(LABEL_SLOT, { provider: group, model, placement, selected }, { fallback: model?.name }))
1236
+ }, model?.name)
824
1237
 
825
1238
  const renderItem = (group, model) => {
826
1239
  const isCurrent = selection !== null &&
@@ -831,9 +1244,9 @@ function createModelPicker(React) {
831
1244
  role: 'option',
832
1245
  'aria-selected': isCurrent,
833
1246
  className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
834
- onClick: () => void select(group, model)
1247
+ onClick: () => void chooseModel(group, model)
835
1248
  },
836
- h('span', { className: 'dmp-item-name' }, renderLabel(group, model, 'option', isCurrent)))
1249
+ h('span', { className: 'dmp-item-name' }, renderLabel(group, model)))
837
1250
  }
838
1251
 
839
1252
  const renderGroup = ({ group, models }) => h('div', { key: group.id },
@@ -842,24 +1255,23 @@ function createModelPicker(React) {
842
1255
 
843
1256
  const notes = []
844
1257
  /**
845
- * Three independent problems, reported separately.
1258
+ * Catalogue problems only.
846
1259
  *
847
- * A catalogue failure belongs to the shared cache and affects every session;
848
- * a selection failure belongs to this session's directory; a provider group
849
- * that failed to load belongs to the catalogue's failure list and explains a
850
- * group that is simply absent. Collapsing them into one message meant a
851
- * failed switch erased the load error and vice versa.
1260
+ * A selection failure is reported by the notice above the panels instead: it
1261
+ * belongs to this session, and the raw `code: message` text is not worth
1262
+ * pushing into the list. `lastActionRef` keeps a selection error from
1263
+ * appearing here as if it were a load failure.
852
1264
  */
853
- const catalogError = typeof catalogState?.error === 'string' ? catalogState.error : ''
854
- const selectionError = typeof state.error === 'string' ? state.error : ''
855
- if (state.status === 'loading' && groups.length === 0 && catalogError.length === 0) {
1265
+ const loadError = state.status === 'error' && lastActionRef.current === 'load' &&
1266
+ typeof state.error === 'string' && state.error.length > 0 ? state.error : ''
1267
+ if (state.status === 'loading' && groups.length === 0) {
856
1268
  notes.push(h('div', { key: 'loading', className: 'dmp-note' }, t.loading))
857
1269
  }
858
- if (catalogError.length > 0 || selectionError.length > 0) {
1270
+ if (loadError.length > 0) {
859
1271
  notes.push(h('div', { key: 'error', className: 'dmp-note dmp-note-error' },
860
- catalogError.length > 0 ? catalogError : selectionError,
1272
+ loadError,
861
1273
  h('div', null, h('button', {
862
- type: 'button', className: 'dmp-retry', onClick: () => catalogFace?.refresh()
1274
+ type: 'button', className: 'dmp-retry', onClick: doLoad
863
1275
  }, t.retry))))
864
1276
  }
865
1277
  // A provider group whose catalogue failed: say so, instead of leaving a
@@ -873,40 +1285,114 @@ function createModelPicker(React) {
873
1285
  notes.push(h('div', { key: 'empty', className: 'dmp-note' }, t.empty))
874
1286
  }
875
1287
 
876
- const effortButton = current === null || currentGroup === null || efforts.length === 0
1288
+ const hasEfforts = efforts.length > 0
1289
+
1290
+ /**
1291
+ * Where each popover sits.
1292
+ *
1293
+ * Both panels are wider than their trigger and open above it, so a fixed side
1294
+ * anchor drifts away from the button and runs off the window near an edge.
1295
+ * The shared hook centres each panel on its trigger and clamps the result to
1296
+ * the viewport; `deps` re-measures when the content width changes (a five-level
1297
+ * slider is wider than a three-level one, and a longer model list is wider than
1298
+ * a short one).
1299
+ */
1300
+ const effortAnchor = createAnchoredPanel(React, {
1301
+ open: panel === 'effort',
1302
+ fallbackWidth: 280,
1303
+ deps: [efforts.length]
1304
+ })
1305
+ const modelAnchor = createAnchoredPanel(React, {
1306
+ open: panel === 'model',
1307
+ fallbackWidth: 320,
1308
+ deps: [filtered.length, groups.length]
1309
+ })
1310
+
1311
+ /**
1312
+ * Measure the notice so the open panel can sit above it instead of under it.
1313
+ *
1314
+ * The panel's `bottom` adds `--dmp-notice-lift`, so this only has to report
1315
+ * the notice's rendered height.
1316
+ */
1317
+ useEffect(() => {
1318
+ const node = noticeRef.current
1319
+ if (failure === null || node === null || typeof node.getBoundingClientRect !== 'function') {
1320
+ setNoticeLift(0)
1321
+ return
1322
+ }
1323
+ const height = node.getBoundingClientRect().height
1324
+ setNoticeLift(Number.isFinite(height) && height > 0 ? height + 6 : 0)
1325
+ }, [failure, noticeOpen])
1326
+
1327
+ const notice = failure === null
1328
+ ? null
1329
+ : h('div', { className: 'dmp-notice', ref: noticeRef, role: 'status' },
1330
+ h('button', {
1331
+ type: 'button',
1332
+ className: 'dmp-notice-toggle',
1333
+ 'aria-expanded': noticeOpen,
1334
+ title: failure.detail,
1335
+ onClick: () => setNoticeOpen((value) => !value)
1336
+ },
1337
+ h('span', { className: 'dmp-notice-text' }, failure.summary),
1338
+ h(Chevron, { h, open: noticeOpen })),
1339
+ noticeOpen && failure.detail.length > 0
1340
+ ? h('div', { className: 'dmp-notice-detail' }, failure.detail)
1341
+ : null)
1342
+
1343
+ const effortButton = current === null || currentGroup === null || !hasEfforts
877
1344
  ? null
878
1345
  : h('button', {
879
1346
  type: 'button',
1347
+ ref: effortAnchor.ref,
880
1348
  className: 'dmp-trigger dmp-trigger-effort',
881
- disabled: locked === true || busy,
882
- title: t.effortTitle(effortName(activeEffort)),
883
- 'aria-haspopup': 'listbox',
1349
+ // Only a locked composer disables the level tag. Its own commits flip the
1350
+ // directory to `selecting`, and disabling here killed the drag after the
1351
+ // first stop — the same reason the slider's range input stays enabled.
1352
+ disabled: locked === true,
1353
+ title: t.effortTitle(effortLabel),
1354
+ 'aria-haspopup': 'dialog',
884
1355
  'aria-expanded': panel === 'effort',
885
1356
  onClick: () => setPanel((value) => (value === 'effort' ? null : 'effort'))
886
1357
  },
887
- h('span', { className: 'dmp-trigger-text' }, effortName(activeEffort)),
1358
+ h('span', { className: 'dmp-trigger-text dmp-trigger-effort-text' }, effortLabel),
888
1359
  h(Chevron, { h, open: panel === 'effort' }))
889
1360
 
890
1361
  const effortPanel = panel === 'effort' && effortButton !== null
891
- ? h('div', { className: 'dmp-panel dmp-panel-narrow', role: 'listbox' },
892
- h('div', { className: 'dmp-list' }, efforts.map((effort) => {
893
- const isCurrent = effort.id === activeEffortId
894
- return h('button', {
895
- key: effort.id,
896
- type: 'button',
897
- role: 'option',
898
- 'aria-selected': isCurrent,
899
- className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
900
- onClick: () => void select(currentGroup, current, effort.id)
901
- }, h('span', { className: 'dmp-item-name' }, effortName(effort)),
902
- effort.description === undefined
903
- ? null
904
- : h('span', { className: 'dmp-item-note' }, String(effort.description)))
905
- })))
1362
+ ? h('div', {
1363
+ className: 'dmp-panel dmp-panel-slider',
1364
+ role: 'dialog',
1365
+ 'aria-label': t.effortHeading,
1366
+ ref: effortAnchor.panelRef,
1367
+ style: effortAnchor.style
1368
+ },
1369
+ h(EffortSlider, {
1370
+ efforts,
1371
+ activeId: activeEffortId,
1372
+ /**
1373
+ * Deliberately NOT disabled while a change is settling.
1374
+ *
1375
+ * `busy` tracks the directory's `status === 'selecting'`, and every stop
1376
+ * the slider passes submits a selection — so the first stop flipped
1377
+ * `busy`, disabled the input mid-gesture, and the drag died. The user
1378
+ * then had to press again for each further stop. A level is a dial: it
1379
+ * stays live, and the draft value keeps the thumb honest while the Host
1380
+ * catches up.
1381
+ */
1382
+ disabled: locked === true,
1383
+ caption: glyphCaption ? undefined : t.effortHeading,
1384
+ t,
1385
+ onChange: (effortId) => { void chooseEffort(effortId) }
1386
+ }))
906
1387
  : null
907
1388
 
908
1389
  const modelPanel = panel === 'model'
909
- ? h('div', { className: 'dmp-panel', role: 'listbox' },
1390
+ ? h('div', {
1391
+ className: 'dmp-panel',
1392
+ role: 'listbox',
1393
+ ref: modelAnchor.panelRef,
1394
+ style: modelAnchor.style
1395
+ },
910
1396
  h('div', { className: 'dmp-search' },
911
1397
  h('input', {
912
1398
  type: 'search',
@@ -928,7 +1414,11 @@ function createModelPicker(React) {
928
1414
  */
929
1415
  if (available === false) return null
930
1416
 
931
- return h('div', { className: 'dmp-root', ref: rootRef },
1417
+ return h('div', {
1418
+ className: 'dmp-root',
1419
+ ref: rootRef,
1420
+ style: noticeLift > 0 ? { '--dmp-notice-lift': `${noticeLift}px` } : undefined
1421
+ },
932
1422
  h('style', null, CSS),
933
1423
  // The model control and the reasoning-level control are deliberately two
934
1424
  // buttons: they are two independent choices, and a model change must not
@@ -936,6 +1426,7 @@ function createModelPicker(React) {
936
1426
  h('div', { className: 'dmp-control' },
937
1427
  h('button', {
938
1428
  type: 'button',
1429
+ ref: modelAnchor.ref,
939
1430
  className: 'dmp-trigger',
940
1431
  disabled: locked === true || busy,
941
1432
  title: t.trigger(currentLabel),
@@ -948,10 +1439,11 @@ function createModelPicker(React) {
948
1439
  ? t.switching
949
1440
  : (current === null || currentGroup === null
950
1441
  ? currentLabel
951
- : renderLabel(currentGroup, current, 'trigger', true))),
1442
+ : renderLabel(currentGroup, current))),
952
1443
  h(Chevron, { h, open: panel === 'model' })),
953
1444
  modelPanel),
954
- effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel))
1445
+ effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel),
1446
+ notice)
955
1447
  }
956
1448
  }
957
1449
 
@@ -959,40 +1451,34 @@ function createModelPicker(React) {
959
1451
  /**
960
1452
  * Client entry: the module-loader registration and the plugin object.
961
1453
  *
962
- * This file is the only browser entry the Client module loader knows about for
963
- * this package. Everything it uses lives in sibling modules under `src/`, and
964
- * `scripts/build.mjs` concatenates them into the served `client.js`.
1454
+ * This plugin replaces ONLY the composer's model-selection UI. Everything behind
1455
+ * it — the per-session directory, the catalogue cache, the invalidation events,
1456
+ * the selection RPC — is the shipped `ui-model-selection` implementation, reached
1457
+ * through its `modelDirectories` service.
965
1458
  *
966
- * Why the picker shadows this slot at all: `conversation.input.model` is a
967
- * `single` slot whose shipped occupant carries `replaceRisk: shadows-shipped-ui`.
968
- * Shadowing it is a visual choice, so it lives in a bundle that can be turned
969
- * off again without touching any provider registration.
1459
+ * That split works because the shipped plugin keeps its slot registration and its
1460
+ * component apart: the registration's `inject` hands the component a plain props
1461
+ * bundle (`available`, a `directory` store, `load`, `select`), and the component
1462
+ * may render however it likes. This entry registers the same slot with the same
1463
+ * `inject` contract and substitutes its own component.
970
1464
  *
971
- * On the `conversation.input.model.label` child slot: the shipped picker declares
972
- * it and provider plugins decorate it. This picker cannot join them — a child
973
- * slot may be declared exactly once and the shipped occupant keeps its
974
- * declaration even while shadowed, so a second declaration throws at
975
- * registration. The renderer then hands an entry a `renderSlot` binding only for
976
- * children that entry declared itself, so not declaring means not rendering. The
977
- * decorations therefore do not appear here and each model is shown by its plain
978
- * name. That is an accepted consequence of shadowing this slot, not an oversight.
1465
+ * Why not copy the shipped bundle instead: a copy registers a second
1466
+ * `ModelDirectoryResolver` under the same service name, the two fight over it,
1467
+ * and the shipped plugin parks in `loading` — which fails the whole renderer
1468
+ * boot. Reusing the service avoids that, and keeps tracking upstream fixes.
979
1469
  */
980
1470
 
981
-
982
-
983
-
984
1471
  const PLUGIN = 'dsh-model-picker'
985
1472
  const SLOT = 'conversation.input.model'
986
- /** The shipped service that owns per-session model directories. */
1473
+ /** The service the shipped picker exposes for per-session model directories. */
987
1474
  const MODEL_DIRECTORIES = 'modelDirectories'
988
1475
  /**
989
1476
  * BELOW the shipped occupant's `priority: 0`.
990
1477
  *
991
1478
  * For a `single` slot the LOWEST priority renders — the registry says so in its
992
1479
  * own error text ("register at a different priority to shadow it (lowest
993
- * renders)"), and `entriesOfSlot` returns the first survivor of a
994
- * priority-sorted list. A larger number would have lost to the shipped picker
995
- * instead of shadowing it.
1480
+ * renders)"). A larger number would lose to the shipped picker instead of
1481
+ * shadowing it.
996
1482
  */
997
1483
  const SHADOW_PRIORITY = -1
998
1484
 
@@ -1001,17 +1487,12 @@ const SHADOW_PRIORITY = -1
1001
1487
  *
1002
1488
  * The packaged desktop app has no devtools, so a client half that fails to
1003
1489
  * register is indistinguishable from one that never loaded. Each stage is posted
1004
- * to the Host half, which logs it next to its own `host 半边已 apply` line: the
1005
- * pair tells the two cases apart immediately.
1490
+ * to the Host half, which logs it beside its own line: the pair tells the two
1491
+ * cases apart immediately.
1006
1492
  *
1007
1493
  * @param stage - a short description of the load stage reached.
1008
1494
  */
1009
1495
  const report = (stage) => {
1010
- // The renderer console reaches the desktop app log, which is captured in every
1011
- // diagnostics bundle. A POST to the Host half is kept as the richer channel,
1012
- // but it cannot be the only one: it is a second thing that can fail (trust
1013
- // fence, no route yet, offline), and a missing report then looks exactly like
1014
- // "apply was never called".
1015
1496
  try {
1016
1497
  console.error(`[model-picker] ${stage}`)
1017
1498
  } catch {
@@ -1032,8 +1513,7 @@ const report = (stage) => {
1032
1513
  /**
1033
1514
  * The failure worth capturing happens *outside* apply: the client composition
1034
1515
  * builds this plugin object, then never applies it, and whatever it threw on the
1035
- * way is only visible in the page — which the packaged desktop app gives no
1036
- * devtools for. Both global hooks forward to the host log instead.
1516
+ * way is only visible in the page.
1037
1517
  *
1038
1518
  * @param value - the thrown value, in any shape.
1039
1519
  * @returns a short description safe to log.
@@ -1070,184 +1550,153 @@ window.__ModuleLoader__.load({
1070
1550
  return {
1071
1551
  name: PLUGIN,
1072
1552
  /**
1073
- * No fiber-level `inject`.
1553
+ * The services this half reads, PLUS the ones the shipped directory service
1554
+ * reads on its own behalf.
1555
+ *
1556
+ * `remote` and `remote.session` look unused here — this file never mentions
1557
+ * them — but `models.directoryFor(sessionId)` calls into the shipped
1558
+ * resolver, and that call runs in THIS entry's fiber. Cordis only exposes a
1559
+ * service to a fiber that declares it, so dropping them makes every
1560
+ * `directoryFor` throw
1074
1561
  *
1075
- * A required service that is not visible from this context keeps the whole
1076
- * entry in `loading`: Cordis never calls `apply`, and the client Loader
1077
- * turns that into `web boot: 1 entry did not activate`, i.e. a dead
1078
- * renderer. Waiting *inside* apply means an unmet dependency costs only the
1079
- * registration instead of the entire boot.
1562
+ * cannot get property "remote.session" without inject
1563
+ *
1564
+ * from inside `inject`. The renderer catches that, retires the entry and
1565
+ * silently paints the shipped picker — the plugin "just disappears".
1566
+ * Declaring what the callee needs is part of calling it, so they stay.
1080
1567
  */
1568
+ inject: ['locale', 'sessions', 'slots', 'remote', 'remote.session'],
1081
1569
  apply(ctx) {
1082
1570
  report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
1083
1571
 
1084
1572
  /**
1085
- * The catalogue is built HERE, in `apply`, but it reads the `remote`
1086
- * service LAZILY.
1087
- *
1088
- * Two facts pull in opposite directions and this is the seam between
1089
- * them:
1090
- *
1091
- * - Event subscriptions must be installed from the plugin's own `apply`
1092
- * fiber. The gateway registers a forwarded-event listener in the
1093
- * CALLING fiber's private event domain, and a subscription made from a
1094
- * `ctx.inject` callback lands in a different domain: `$on` returns a
1095
- * disposer, so it looks successful, but the listener never fires.
1096
- * - `remote` is frequently NOT visible yet when `apply` runs. Capturing
1097
- * `ctx.get('remote')` here froze `undefined`, and every catalogue load
1098
- * then answered "modelCatalog is unavailable".
1099
- *
1100
- * So the catalogue holds a resolver, not the face, and the subscription
1101
- * is attached as soon as the service appears.
1102
- */
1103
- const remoteFace = () => {
1104
- try { return ctx.get('remote') } catch { return undefined }
1105
- }
1106
- const catalog = createCatalog(remoteFace, (message) => { report(message) })
1107
- /**
1108
- * Attach the invalidation listeners, retrying once the remote exists.
1573
+ * Register the seat through the shipped directory service.
1109
1574
  *
1110
- * `ctx.inject` runs the callback as soon as the named services are
1111
- * visible, which is the point at which `$on` can be reached — and it runs
1112
- * in this same `apply` fiber, so the subscription still lands in the
1113
- * dispatching domain.
1575
+ * `ctx.inject` runs this callback once the named services are visible, so
1576
+ * the registration lands at the same point the shipped one would. It is
1577
+ * also re-run if a service goes away and returns, which is what keeps the
1578
+ * seat from binding to `undefined` across a reconnect.
1114
1579
  */
1115
- let detachCatalog = () => {}
1116
- const attachCatalog = () => {
1117
- const remote = remoteFace()
1118
- if (remote === undefined) return false
1119
- detachCatalog = catalog.attach(remote, (event) => {
1120
- report(`收到失效事件 ${event}`)
1121
- })
1122
- report(`失效订阅:已挂到 remote.$on`)
1123
- return true
1124
- }
1125
- if (!attachCatalog()) {
1126
- report('remote 尚未就绪,等它出现后再挂失效订阅')
1127
- if (typeof ctx.inject === 'function') {
1128
- ctx.inject(['remote'], () => { attachCatalog() })
1580
+ // `remote` / `remote.session` are declared here as well as on the plugin:
1581
+ // this callback runs in the fiber that `ctx.inject` creates, and the
1582
+ // directory resolver uses those two services through that context.
1583
+ ctx.inject(['slots', MODEL_DIRECTORIES, 'remote', 'remote.session'], (scope) => {
1584
+ const slots = scope.get('slots')
1585
+ const sessions = scope.get('sessions')
1586
+ /**
1587
+ * The locale SERVICE, handed to the component as an observable source.
1588
+ *
1589
+ * Its snapshot is `{ active, locales, revision }` — NOT `{ locale }`.
1590
+ * Reading the wrong field made every UI string fall back to Chinese no
1591
+ * matter which language the shell was set to.
1592
+ *
1593
+ * It is passed through as a `{ getSnapshot, subscribe }` pair rather
1594
+ * than resolved to a string here: `inject` is cached per session
1595
+ * binding, so a string captured now would never follow a language
1596
+ * switch made while the composer is on screen.
1597
+ */
1598
+ const locale = scope.get('locale')
1599
+ const localeSource = locale !== undefined &&
1600
+ typeof locale.getSnapshot === 'function' &&
1601
+ typeof locale.subscribe === 'function'
1602
+ ? locale
1603
+ : undefined
1604
+ const initialLocale = localeSource?.getSnapshot?.()?.active
1605
+ const localeId = typeof initialLocale === 'string' && initialLocale.length > 0
1606
+ ? initialLocale
1607
+ : 'zh-CN'
1608
+
1609
+ if (slots === undefined) {
1610
+ report('槽位未注册:slots 缺失')
1611
+ return
1129
1612
  }
1130
- }
1131
- const detachReset = typeof ctx.on === 'function'
1132
- ? ctx.on('connection/reset', () => { catalog.resetGeneration() })
1133
- : undefined
1134
- const sessions = (() => {
1135
- try { return ctx.get('sessions') } catch { return undefined }
1136
- })()
1137
- const shipped = (() => {
1138
- try { return ctx.get(MODEL_DIRECTORIES) } catch { return undefined }
1139
- })()
1140
- const directories = shipped !== undefined && typeof shipped.directoryFor === 'function'
1141
- ? shipped
1142
- : createDirectoryService(catalog, sessions, remoteFace)
1143
- const ownsDirectories = directories !== shipped
1144
- report(`目录来源:${ownsDirectories ? '本插件自带实现' : '复用官方 modelDirectories'}`)
1145
- const detachDirectories = ownsDirectories && typeof ctx.provide === 'function'
1146
- ? ctx.provide(MODEL_DIRECTORIES, directories)
1147
- : undefined
1148
- if (typeof ctx.effect === 'function') {
1149
- ctx.effect(() => {
1150
- detachCatalog()
1151
- detachReset?.()
1152
- detachDirectories?.()
1153
- }, 'dsh-model-picker: catalogue and directories')
1154
- }
1155
-
1156
- const install = (scope) => {
1157
- try {
1158
- const at = scope ?? ctx
1159
- const slots = at.slots ?? ctx.get('slots')
1160
- if (slots === undefined) {
1161
- report('slots 服务仍不可用,槽位未注册')
1162
- return
1613
+
1614
+ /**
1615
+ * Register the seat.
1616
+ *
1617
+ * Two details matter:
1618
+ *
1619
+ * - NO `children` table. The child slot `conversation.input.model.label`
1620
+ * is declared by the shipped occupant, and a child declaration is
1621
+ * EXCLUSIVE per slot key: `slots.register` throws
1622
+ * `slot "…label" is already declared (by …)` the moment a second entry
1623
+ * offers the same child. The throw escapes the slot callback and the
1624
+ * picker stops rendering. Disposing first does not help either — the
1625
+ * declaration belongs to the shipped entry, which never disposes.
1626
+ *
1627
+ * - The service lookup happens INSIDE the callback. `models` may not be
1628
+ * visible yet when `apply` runs; reading it here means the registration
1629
+ * waits for it instead of binding to undefined.
1630
+ *
1631
+ * The cost of dropping the child declaration is that provider badges
1632
+ * mounted at `conversation.input.model.label` no longer render here. That
1633
+ * is the same trade the reference plugin makes, and it is what makes the
1634
+ * registration survive.
1635
+ */
1636
+ let disposeCurrent = null
1637
+ slots.inject(SLOT, () => {
1638
+ const models = scope.get(MODEL_DIRECTORIES)
1639
+ if (models === undefined) {
1640
+ report('modelDirectories 缺失,暂不注册')
1641
+ return () => {}
1163
1642
  }
1164
- const locale = ctx.get('locale')
1165
- const localeId = typeof locale?.getLocale === 'function'
1166
- ? String(locale.getLocale()?.locale ?? 'zh-CN')
1167
- : 'zh-CN'
1168
- /**
1169
- * Neither `ctx.remote` nor `ctx.remote.session` can be read through
1170
- * the property proxy without an `inject` entry — Cordis throws
1171
- * `cannot get property "remote.session" without inject`, which crashed
1172
- * this component on its first render and got the entry abdicated.
1173
- *
1174
- * `ctx.get(...)` returns `undefined` instead of throwing, so the
1175
- * picker degrades to its "could not load the model list" state rather
1176
- * than taking the whole slot down.
1177
- */
1178
- const remoteSession = at['remote.session'] ?? ctx.get('remote.session') ?? remoteFace()?.session
1179
-
1180
- slots.inject(SLOT, () => {
1181
- report('槽位回调已运行,准备注册 occupant')
1182
- /**
1183
- * NO `children` declaration, on purpose.
1184
- *
1185
- * A child slot may be declared exactly once, and the shipped picker
1186
- * already declares `conversation.input.model.label` for its own
1187
- * occupant. Declaring it here threw during registration, which is
1188
- * what removed the shipped occupant and left the slot empty.
1189
- *
1190
- * The consequence is that provider decorations mounted on the label
1191
- * child do not render while this picker is in charge. Rendering them
1192
- * would require owning the declaration, i.e. the shipped
1193
- * `ui-model-selection` occupant being absent entirely.
1194
- */
1195
- const dispose = slots.register({
1196
- name: SLOT,
1197
- priority: SHADOW_PRIORITY,
1198
- inject: (sessionId) => {
1199
- const sessionSessions = ctx.get('sessions')
1200
- /**
1201
- * The per-session directory is the data face of this picker.
1202
- *
1203
- * `directoryFor` is the shipped service's own entry point; when
1204
- * this plugin had to provide the service, the fallback has the
1205
- * same signature, so the component never knows the difference.
1206
- */
1207
- const directory = directories.directoryFor(sessionId)
1208
- // The shipped directory projects inside its constructor and on
1209
- // its subscriptions; it exposes no `sync`. Only this plugin's
1210
- // fallback has one, so probe instead of assuming — calling it
1211
- // unconditionally threw on the shipped service and took the
1212
- // whole slot registration down with it.
1213
- if (typeof directory?.sync === 'function') directory.sync()
1643
+ // The callback can fire more than once (a service re-appearing re-runs
1644
+ // it). Registering twice at the same priority throws
1645
+ // `single slot "…" already has a registration`, so the previous one is
1646
+ // released first. Without a children table this is safe — the child
1647
+ // declaration is what cannot be released.
1648
+ if (disposeCurrent !== null) {
1649
+ try {
1650
+ disposeCurrent()
1651
+ } catch {
1652
+ // Already gone.
1653
+ }
1654
+ disposeCurrent = null
1655
+ }
1656
+ report('槽位回调已运行,准备注册 occupant')
1657
+ disposeCurrent = slots.register({
1658
+ name: SLOT,
1659
+ priority: SHADOW_PRIORITY,
1660
+ inject: (sessionId) => {
1661
+ const directory = models.directoryFor(sessionId)
1662
+ const available = typeof sessions?.subagentAddress === 'function'
1663
+ ? sessions.subagentAddress(sessionId) === undefined
1664
+ : true
1665
+ return {
1666
+ localeId,
1667
+ localeSource,
1668
+ available,
1669
+ // The shipped registration hands over the STORE, not the
1670
+ // controller; the component subscribes to it directly.
1671
+ directory: directory.store,
1672
+ load: () => {
1673
+ if (available) directory.load().catch(() => {})
1674
+ },
1214
1675
  /**
1215
- * The directory's own snapshot, reported once per registration.
1676
+ * Submit one triple.
1216
1677
  *
1217
- * This is the line that tells the two "not refreshed" failure
1218
- * modes apart: if the group count here stops matching what the
1219
- * Host now offers, the directory is stale; if it tracks the Host
1220
- * while the panel still shows old rows, the panel is not
1221
- * rendering the update.
1678
+ * The RESULT IS PASSED THROUGH UNCHANGED. `ModelDirectory.select`
1679
+ * resolves to `{ ok: true, value }` or `{ ok: false, error }`,
1680
+ * and the component — like the shipped one — reads `ok` to
1681
+ * decide whether to close the panel and which failure to show.
1682
+ * Mapping it to a boolean here made every successful pick look
1683
+ * like a failure: the model switched but the list stayed open,
1684
+ * and a settled effort level dropped its optimistic draft.
1222
1685
  */
1223
- const snapshot = directory?.store?.getSnapshot?.()
1224
- report(`目录快照:${Array.isArray(snapshot?.groups) ? snapshot.groups.length : 0} 组 / 状态 ${snapshot?.status ?? '?'}`)
1225
- return {
1226
- sessionId,
1227
- localeId,
1228
- remote: remoteFace(),
1229
- remoteSession,
1230
- sessions: sessionSessions,
1231
- catalog,
1232
- directory,
1233
- available: isAvailable(sessionSessions, sessionId)
1234
- }
1686
+ select: (selection) => available
1687
+ ? directory.select(selection)
1688
+ : Promise.resolve(undefined)
1235
1689
  }
1236
- }, ModelPicker)
1237
- report('occupant 已注册(priority ' + SHADOW_PRIORITY + ')')
1238
- return dispose
1239
- })
1240
- } catch (error) {
1241
- report(`槽位注册抛错:${error instanceof Error ? `${error.message}` : String(error)}`)
1242
- }
1243
- }
1244
-
1245
- // Soft wait: `apply` has already run, so a service that never shows up
1246
- // can no longer fail the boot. The remotes are included because the
1247
- // picker cannot do anything without them, and waiting here keeps the
1248
- // failure to a missing control instead of a crashed one.
1249
- if (typeof ctx.inject === 'function') ctx.inject(['slots', 'remote', 'remote.session'], install)
1250
- else install(ctx)
1690
+ }
1691
+ }, ModelPicker)
1692
+ return () => {
1693
+ if (disposeCurrent !== null) {
1694
+ try { disposeCurrent() } catch { /* already gone */ }
1695
+ disposeCurrent = null
1696
+ }
1697
+ }
1698
+ })
1699
+ })
1251
1700
  }
1252
1701
  }
1253
1702
  }