@huai_xin/dsh-model-picker 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -4
  2. package/client.js +1454 -836
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -5,561 +5,563 @@
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.
127
+ *
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.
297
132
  *
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).
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:
304
135
  *
305
- * The store contract is the same one the renderer's hooks read:
306
- * `{ current, routable, groups, failures, status, pending, error }`.
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.
307
141
  *
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.
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' } }
154
+ useEffect(() => {
155
+ if (!open) {
156
+ setAnchor({ offset: 0, ready: false })
157
+ return undefined
410
158
  }
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
- }
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 })
424
185
  }
425
- if (disposed || generationAt !== generation) {
426
- return result?.ok === true ? { ok: true, value: undefined } : result
186
+
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
+ }
427
199
  }
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
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])
205
+
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'
435
214
  }
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
215
  }
216
+ }
462
217
 
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
- }
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'
474
231
 
475
- /** Ensure the shared catalogue is loaded, then re-project. */
476
- const load = async () => {
477
- await catalog.load()
478
- sync()
479
- return store.getSnapshot()
480
- }
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
+ ]
481
239
 
482
- /** Scope teardown: late settlements lose write access to the store. */
483
- const dispose = () => {
484
- disposed = true
485
- unsubscribeCatalog?.()
486
- unsubscribeProjection?.()
487
- }
240
+ //# module: src/ui/CaptionMark.js
488
241
 
489
- return { store, select, load, resetConnected, dispose, sync }
490
- }
242
+ /**
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.
249
+ *
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.
253
+ *
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.
259
+ */
260
+ function createCaptionMark(React) {
261
+ const h = React.createElement
491
262
 
492
- //# module: src/core/directories.js
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))
291
+ }
292
+ }
493
293
 
294
+ //# module: src/ui/EffortSlider.js
494
295
  /**
495
- * The `modelDirectories` service: one directory per session, shared by
496
- * everything that renders a model seat.
296
+ * A discrete reasoning slider over the model's declared levels.
297
+ *
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.
497
318
  *
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.
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.
503
325
  *
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`.
326
+ * @param props - `{ h, efforts, activeId, disabled, onChange, t, caption }`.
327
+ * @returns the slider element tree.
510
328
  */
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
- }
329
+
330
+ function createEffortSlider(React) {
331
+ const h = React.createElement
332
+ const CaptionMark = createCaptionMark(React)
333
+ const { useState, useEffect, useRef } = React
516
334
 
517
335
  /**
518
- * Keyed by session id, not by binding object.
336
+ * The exhaust table.
337
+ *
338
+ * Fixed numbers, never `Math.random()`: a render has to be pure, and the stream
339
+ * must look identical every time the panel opens.
519
340
  *
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.
341
+ * `dy0` is what makes the particles leave the knob's whole CROSS-SECTION rather
342
+ * than one point in its centre — it spreads the spawn points down the knob's
343
+ * height. `dx` / `dy` / `sz` / `rot` stop the squares from marching in step, and
344
+ * the per-particle `--i` turns one keyframe into a continuous stream (see the
345
+ * negative `animation-delay` in the stylesheet).
527
346
  */
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
543
- }
347
+ const EMIT = [
348
+ { dy0: -11, dx: -58, dy: -3, sz: 4, rot: 0 },
349
+ { dy0: -7, dx: -74, dy: -5, sz: 3, rot: 45 },
350
+ { dy0: -3, dx: -62, dy: -1, sz: 5, rot: 0 },
351
+ { dy0: 1, dx: -88, dy: 2, sz: 3, rot: 90 },
352
+ { dy0: 5, dx: -66, dy: 4, sz: 4, rot: 0 },
353
+ { dy0: 9, dx: -80, dy: 6, sz: 3, rot: 45 },
354
+ { dy0: -9, dx: -92, dy: -6, sz: 3, rot: 90 },
355
+ { dy0: -5, dx: -54, dy: -2, sz: 4, rot: 0 },
356
+ { dy0: -1, dx: -70, dy: 0, sz: 3, rot: 45 },
357
+ { dy0: 3, dx: -84, dy: 3, sz: 4, rot: 0 },
358
+ { dy0: 7, dx: -60, dy: 5, sz: 3, rot: 90 },
359
+ { dy0: 11, dx: -76, dy: 7, sz: 3, rot: 0 },
360
+ { dy0: -8, dx: -68, dy: -4, sz: 4, rot: 45 },
361
+ { dy0: 0, dx: -90, dy: 1, sz: 3, rot: 0 },
362
+ { dy0: 6, dx: -52, dy: 4, sz: 4, rot: 90 },
363
+ { dy0: -2, dx: -78, dy: -1, sz: 3, rot: 0 }
364
+ ]
365
+
366
+ return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
367
+ const count = efforts.length
368
+ const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
544
369
 
545
- return { directoryFor, directories }
546
- }
370
+ /**
371
+ * The stop under the pointer, which is ahead of `activeId` while dragging.
372
+ *
373
+ * `null` means "no drag in progress, follow the prop".
374
+ */
375
+ const [dragging, setDragging] = useState(null)
376
+ const index = dragging ?? incoming
377
+ const active = efforts[index]
378
+ const activeName = active?.name ?? active?.id ?? ''
379
+ const inputRef = useRef(null)
380
+
381
+ // A prop change from outside (another entry changed the level, the session
382
+ // switched model) wins over a stale drag value.
383
+ useEffect(() => { setDragging(null) }, [activeId, efforts.length])
384
+
385
+ // The DOM value follows the level unless a gesture owns it. Writing during a
386
+ // drag would fight the pointer, so `dragging` gates it.
387
+ useEffect(() => {
388
+ if (dragging !== null) return
389
+ const input = inputRef.current
390
+ if (input !== null && typeof input === 'object' && 'value' in input) {
391
+ input.value = String(incoming)
392
+ }
393
+ }, [incoming, dragging])
394
+
395
+ // A model with just one effort keeps its stop in the middle.
396
+ const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
397
+
398
+ return h('div', {
399
+ // `dmp-slider-dragging` drops the left transition on the knob, the fill and
400
+ // the emitter together. Without it the knob eases to its new stop over 150ms
401
+ // while a transition-free emitter jumps there at once, so the particles left
402
+ // from a position the knob had not reached yet.
403
+ className: `dmp-slider${dragging === null ? '' : ' dmp-slider-dragging'}`,
404
+ 'aria-disabled': disabled === true,
405
+ style: { '--dmp-effort-at': position(index) }
406
+ },
407
+ h('div', { className: 'dmp-slider-head' },
408
+ h('span', { className: 'dmp-slider-caption' },
409
+ // The vector marks are CJK outlines; a Latin heading is plain text.
410
+ caption === undefined
411
+ ? h(CaptionMark, { label: t.effortHeading, height: 16 })
412
+ : h('span', { className: 'dmp-caption-text' }, caption)),
413
+ h('span', { className: 'dmp-slider-value' }, activeName)),
414
+
415
+ h('div', { className: 'dmp-slider-rail' },
416
+ /**
417
+ * The rail's outline, drawn as vector art.
418
+ *
419
+ * This started as a `box-shadow` inset, which is a RASTER edge: at 1px it
420
+ * lands on the pixel grid but reads flat, and at 1.5px it falls between
421
+ * pixels and smears into a grey band. The knob's ring never had that problem
422
+ * because it is an SVG stroke — a vector edge, crisp at any scale.
423
+ *
424
+ * So the outline uses the same primitive as the knob: a `<rect>` with a
425
+ * stroked edge, sized to the rail and scaled to it by `preserveAspectRatio`
426
+ * being off. `vector-effect: non-scaling-stroke` keeps the stroke exactly
427
+ * 1px however the rect is stretched, so the line stays hairline-crisp instead
428
+ * of being squashed by the horizontal stretch. `rx` is a percentage of the
429
+ * half-height, matching the rail's own rounded ends.
430
+ */
431
+ h('svg', {
432
+ className: 'dmp-slider-outline',
433
+ 'aria-hidden': true,
434
+ preserveAspectRatio: 'none'
435
+ },
436
+ h('rect', {
437
+ // Half the stroke on each axis: an SVG stroke straddles the path, so a 2px
438
+ // line on the exact edge would be half-clipped by the rail's clip-path.
439
+ x: 1,
440
+ y: 1,
441
+ width: 'calc(100% - 2px)',
442
+ height: 'calc(100% - 2px)',
443
+ rx: 14,
444
+ ry: 14,
445
+ fill: 'none',
446
+ stroke: 'var(--dsw-alias-border-l3)',
447
+ strokeWidth: 2,
448
+ vectorEffect: 'non-scaling-stroke'
449
+ })),
450
+ /**
451
+ * The moving parts live in an INSET track.
452
+ *
453
+ * Stops are drawn inside the rail rather than on its very edge — a dot
454
+ * sitting flush against the rounded end reads as part of the border, not
455
+ * as a stop. The track is inset by the knob's radius on both sides, which
456
+ * is also exactly the knob's own travel: the native range input spans the
457
+ * rail with a thumb that size, so its centre runs from inset to width
458
+ * minus inset. Keeping all three — fill, dots, knob — on THIS box means
459
+ * the percentage positions from `--dmp-effort-at` line up without any
460
+ * per-element inset arithmetic.
461
+ */
462
+ h('div', { className: 'dmp-slider-track' },
463
+ h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
464
+ /**
465
+ * The exhaust.
466
+ *
467
+ * An empty box pinned at the knob's position (`--dmp-effort-at`), carrying
468
+ * the particle squares. It is a SIBLING of the knob inside the same inset
469
+ * track, so it shares the knob's coordinate space and its transition —
470
+ * which is what keeps the spawn point under the knob while it moves.
471
+ * The rail's clip-path trims whatever drifts past the end cap.
472
+ */
473
+ h('span', { className: 'dmp-emit', 'aria-hidden': true },
474
+ EMIT.map((particle, at) => h('i', {
475
+ key: at,
476
+ className: 'dmp-emit-p',
477
+ style: {
478
+ '--i': at,
479
+ '--dx': `${particle.dx}px`,
480
+ '--dy0': `${particle.dy0}px`,
481
+ '--dy': `${particle.dy}px`,
482
+ '--sz': `${particle.sz}px`,
483
+ '--rot': `${particle.rot}deg`
484
+ }
485
+ }))),
486
+ h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
487
+ efforts.map((effort, at) => h('span', {
488
+ key: effort.id,
489
+ className: `dmp-slider-dot${at <= index ? ' dmp-slider-dot-lit' : ''}`,
490
+ style: { left: position(at) }
491
+ }))),
492
+ /**
493
+ * The knob is an SVG circle, not a border-radius box.
494
+ *
495
+ * A `<div>` with `border-radius` is only round as long as nothing else
496
+ * controls its box, and something in the host does: the same CSS that is
497
+ * perfectly round in isolation rendered as a rounded rectangle inside the
498
+ * app, at every radius value tried. An SVG circle's shape comes from its
499
+ * geometry, so no `border-radius`, `padding`, or inherited box rule can
500
+ * square it off.
501
+ */
502
+ h('svg', {
503
+ className: 'dmp-slider-thumb',
504
+ viewBox: '0 0 28 28',
505
+ 'aria-hidden': true
506
+ },
507
+ h('circle', {
508
+ // Outer edge = r + stroke/2 = 13.25 + 0.75 = 14 units, and the 28-unit
509
+ // box is drawn at 32px — so the disc's radius is exactly 16px, the same
510
+ // as the rail's end cap. Smaller and the blue cap peeks out as a thin
511
+ // rim around the knob at the first stop; larger and the knob spills
512
+ // past the pill.
513
+ cx: 14,
514
+ cy: 14,
515
+ r: 13.25,
516
+ // Fill and ring come from CSS so the two themes can differ: the dark
517
+ // theme keeps the knob the panel's own grey with a black ring, the light
518
+ // theme flips the fill to white and keeps the black ring.
519
+ fill: 'var(--dmp-thumb-fill, currentColor)',
520
+ stroke: 'var(--dmp-thumb-ring, #000)',
521
+ strokeWidth: 1.5
522
+ }))),
523
+ h('input', {
524
+ type: 'range',
525
+ ref: inputRef,
526
+ className: 'dmp-slider-input',
527
+ min: 0,
528
+ max: Math.max(0, count - 1),
529
+ step: 1,
530
+ // Uncontrolled: the DOM owns the position during a gesture, so a parent
531
+ // render cannot snap it back. `defaultValue` seeds it on mount, and the
532
+ // effect above re-seeds it whenever the level changes from outside.
533
+ defaultValue: incoming,
534
+ disabled: disabled === true || count <= 1,
535
+ 'aria-label': t.effortHeading,
536
+ 'aria-valuetext': activeName,
537
+ // Live feedback only — no Host call, so the thumb tracks at full speed.
538
+ onInput: (event) => {
539
+ const at = Number(event.target.value)
540
+ if (Number.isFinite(at)) setDragging(at)
541
+ },
542
+ // The commit: browsers fire `change` on release (and on every key press).
543
+ onChange: (event) => {
544
+ const at = Number(event.target.value)
545
+ const next = efforts[at]
546
+ setDragging(null)
547
+ if (next !== undefined) onChange(next.id)
548
+ }
549
+ })),
547
550
 
548
- //# module: src/ui/Chevron.js
549
- /**
550
- * The chevron that turns when its panel is open.
551
- *
552
- * @param props - `{ h, open }`: the createElement function and the open flag.
553
- * @returns the svg element.
554
- */
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
- }))
551
+ h('div', { className: 'dmp-slider-legend' },
552
+ efforts.map((effort, at) => h('button', {
553
+ key: effort.id,
554
+ type: 'button',
555
+ className: `dmp-slider-tick${at === index ? ' dmp-slider-tick-active' : ''}`,
556
+ disabled: disabled === true,
557
+ 'aria-pressed': at === index,
558
+ 'data-edge': count <= 1 ? undefined : at === 0 ? 'start' : at === count - 1 ? 'end' : undefined,
559
+ title: effort.name ?? effort.id,
560
+ style: { left: position(at) },
561
+ onClick: () => onChange(effort.id)
562
+ }, effort.name ?? effort.id))),
563
+ h('p', { className: 'dmp-slider-hint' }, t.effortHint))
564
+ }
563
565
  }
564
566
 
565
567
  //# module: src/ui/styles.js
@@ -567,55 +569,417 @@ function Chevron({ h, open }) {
567
569
  * The picker's stylesheet.
568
570
  *
569
571
  * 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
572
+ * controls no longer fit on one line. Every class is prefixed dmp- so a copied
571
573
  * host pattern can never collide with the host's own rules.
574
+ *
575
+ * Sizing follows the shipped menu rather than inventing its own scale: the panel
576
+ * is max-content wide (so a short list stays narrow instead of stretching to a
577
+ * fixed width), rows are 34px tall with 5px/7px padding, and the panel pads 4px.
578
+ * Those numbers are what makes the replacement feel like part of the composer
579
+ * instead of a dialog dropped on top of it.
572
580
  */
573
581
  const CSS = `
574
- .dmp-root { position: relative; display: flex; align-items: center; gap: 2px; min-width: 0; }
582
+ .dmp-root { position: relative; display: flex; align-items: center; gap: 6px; min-width: 0; }
575
583
  /* One popover anchor per control: the model list and the level list open above
576
584
  their own button and never overlap each other. */
577
585
  .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;
586
+ /* The model tag keeps a stable box too, for the same reason: switching models must
587
+ not move the level tag beside it. It is capped rather than fixed so a long model
588
+ name still gets room, but the cap is what the layout reserves. */
589
+ .dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0;
590
+ max-width: min(220px, 34cqw); height: 28px; padding: 0 4px 0 8px; border: 0;
591
+ border-radius: var(--dsw-radius-sm, 6px); cursor: pointer;
580
592
  background: transparent; color: var(--dsw-alias-label-secondary);
581
593
  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); }
594
+ /* The level tag has a FIXED width.
595
+ Level names differ in length ("Low" vs "X-High"), so a content-sized tag made
596
+ the whole composer row shift every time the level changed — and pushed the
597
+ model button sideways with it. A fixed box keeps both controls still.
598
+
599
+ The width is sized to the longest name a provider is likely to send: "Medium"
600
+ measures ~50px at 12px/600, and the earlier 74px box only left 44px for text,
601
+ so it rendered as "Medi…". 92px leaves 56px, which also covers "Extreme" and
602
+ "Minimal".
603
+
604
+ The label is CENTRED in the button, which needs the caret out of the flex flow:
605
+ a normal flex child reserves 18px on the right only, so centring the label in
606
+ what was left pushed it ~7px left of the button's true centre. Absolutely
607
+ positioning the caret and giving the button 18px padding on BOTH sides makes
608
+ the label's own box symmetric, so text-align: center lands on the button's
609
+ centre. 18px is exactly the caret's own width (14px) plus its 4px inset, which
610
+ also stops a long label from running underneath it.
611
+ The caret keeps its transform free for the open-state rotation by centring
612
+ with margin-top instead of translateY. */
613
+ .dmp-trigger-effort { position: relative; width: 92px; box-sizing: border-box;
614
+ padding: 0 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
615
+ .dmp-trigger-effort > .dmp-chevron { position: absolute; right: 4px; top: 50%;
616
+ margin-top: -7px; }
617
+ /* The tag names the level; it is not a brightness meter. Taking the ramp colour here
618
+ made "Low" render dim grey and "High" near white, so the control appeared to change
619
+ weight as the level moved. The ramp stays on the track, where it means intensity. */
620
+ .dmp-trigger-effort-text { flex: 1 1 auto; min-width: 0; text-align: center;
621
+ color: var(--dsw-alias-label-primary); font-weight: 600; }
622
+ /* Hover and open share ONE background, and it is the translucent interactive
623
+ token, not the opaque layer-2 surface.
624
+ The layer-2 token resolves to the composer's own surface colour in both themes
625
+ (light white on white, dark 2c2c2e on 2c2c2e), so hovering this tag painted
626
+ nothing at all — the level button was the only control in the row with no
627
+ pointer feedback. The translucent token composes over whatever is behind it
628
+ and matches the model tag beside it. */
629
+ .dmp-trigger-effort:hover:not(:disabled),
630
+ .dmp-trigger-effort[aria-expanded="true"] {
631
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-interactive-bg-active));
632
+ color: var(--dsw-alias-label-primary); }
633
+ .dmp-trigger-effort:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
584
634
  .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; }
635
+ .dmp-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-state-idle-primary)); cursor: default; }
587
636
  .dmp-trigger-text { display: var(--dsh-composer-model-text-display, block);
588
637
  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; }
638
+ .dmp-chevron { flex: none; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary));
639
+ transition: transform .12s; }
590
640
  .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;
641
+ /* Content-sized, like the shipped menu: a two-model list must not be as wide as a
642
+ twenty-model one. The caps only exist so a long model name cannot run off the
643
+ viewport. */
644
+ .dmp-panel { position: absolute; bottom: calc(100% + 6px + var(--dmp-notice-lift, 0px)); z-index: 1000;
645
+ width: max-content; min-width: min(240px, 100vw - 32px); max-width: min(360px, 92vw);
646
+ max-height: min(320px, 60vh); display: flex; flex-direction: column; padding: 4px;
647
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-lg, 10px);
648
+ background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
649
+ box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv3));
650
+ color: var(--dsw-alias-label-primary); overflow: hidden; }
651
+ .dmp-search { flex: none; padding: 0 0 4px; }
652
+ .dmp-search input { width: 100%; box-sizing: border-box; padding: 5px 7px; font: inherit; font-size: 12.5px;
653
+ color: var(--dsw-alias-label-primary); background: transparent;
654
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px); outline: none; }
655
+ .dmp-search input:focus-visible { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
656
+ /* The scroll container.
657
+ *
658
+ * The scrollbar is NOT styled here. The host owns scrollbar appearance through two
659
+ * CSS variables that its own stylesheets consume, so setting them on the panel is
660
+ * the whole job: the shell then paints a thin, rounded, theme-aware bar that matches
661
+ * every other DSH popover. Writing -webkit-scrollbar rules as well would override
662
+ * the host's treatment and is what made this panel look different from the rest of
663
+ * the app.
664
+ *
665
+ * This mirrors the shipped picker and the thinking-slider plugin, which both do
666
+ * exactly this much. */
667
+ .dmp-list { flex: 1; overflow-y: auto; min-height: 0;
668
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
669
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); }
670
+ .dmp-group { padding: 6px 7px 2px; font-size: 11px; letter-spacing: .04em;
671
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
672
+ .dmp-item { display: flex; align-items: center; width: 100%; box-sizing: border-box;
673
+ min-height: 34px; padding: 5px 7px; border: 0; border-radius: var(--dsw-radius-md, 8px);
674
+ background: transparent; cursor: pointer; text-align: left;
606
675
  color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
607
676
  .dmp-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
608
677
  .dmp-item-current { color: var(--dsw-alias-brand-primary); font-weight: 600; }
609
678
  .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); }
679
+ .dmp-note { padding: 8px 7px; font-size: 12px; line-height: 18px;
680
+ color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
615
681
  .dmp-note-error { color: var(--dsw-alias-state-error-primary); }
616
682
  .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
- `
683
+ border-radius: var(--dsw-radius-sm, 6px); background: transparent; color: inherit;
684
+ font: inherit; font-size: 12px; cursor: pointer; }
685
+
686
+ /* The padding is what keeps the heading and the hint off the panel edge; the values
687
+ are generous because this panel has no menu rows to create natural breathing room. */
688
+ .dmp-panel-slider { width: max-content; box-sizing: border-box;
689
+ min-width: min(322px, 100vw - 32px); max-width: min(374px, 100vw - 32px);
690
+ padding: 17px 19px 15px; border-radius: var(--dsw-radius-lg, 12px); }
691
+ /* The type stack, in two parts.
692
+ *
693
+ * The level names are Latin ("Low", "Medium", "X-High") and get the host's own
694
+ * brand face, Montserrat, which the theme plugin already ships at 300/400/500 —
695
+ * so this costs no download and matches the host's own headings. The CJK copy
696
+ * ("思考强度", the hint line) has no glyphs in Montserrat, so the stack falls
697
+ * through to the platform UI face, which is what the host uses for body text.
698
+ *
699
+ * Nothing here sets a size: that stays with each element, so a host font-size
700
+ * change still reaches this control. The slider inherits this from the panel it is
701
+ * always rendered inside, so it is not named here. */
702
+ .dmp-panel {
703
+ font-family: Montserrat, var(--dsw-font-family-sans, system-ui, -apple-system,
704
+ 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', sans-serif); }
705
+ .dmp-slider { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
706
+ .dmp-slider-head { display: flex; align-items: center; gap: 14px; margin-bottom: 5px; }
707
+ /* The heading is vector art, not text: see CaptionMark.js for why. It only needs a
708
+ colour — which it takes from the theme token below through currentColor — and a
709
+ display box that sits it on the same line as the value pill. */
710
+ .dmp-slider-caption { display: flex; align-items: center;
711
+ color: var(--dsw-alias-label-primary); }
712
+ .dmp-caption-mark { display: block; width: auto; }
713
+ /* The Latin heading, used wherever the vector mark would be unreadable. It takes
714
+ the heading's own size so the two stay interchangeable on the same line. */
715
+ .dmp-caption-text { display: block; font-size: 13px; font-weight: 600; line-height: 16px;
716
+ letter-spacing: .01em; white-space: nowrap; }
717
+ /* The value pill is sized close to the heading beside it, so the two read as one
718
+ line: the earlier 19px box with 11.5px type made the current level look like a
719
+ footnote next to the title. 26px / 14px keeps the pair balanced and still fits
720
+ a long level name. */
721
+ .dmp-slider-value { margin-left: auto; display: inline-flex; align-items: center;
722
+ height: 22px; box-sizing: border-box; padding: 0 8px; border-radius: 8px;
723
+ font-size: 12px; line-height: 1; font-weight: 600; white-space: nowrap;
724
+ letter-spacing: .01em;
725
+ /* The pill is a label, not a rank meter: it must read the SAME at every level.
726
+ It used to take the level's ramp colour, so Low rendered dim grey and Max near
727
+ white — the same component looking like two different things depending on the
728
+ setting. The ramp colour stays on the track, where it encodes intensity. */
729
+ color: var(--dsw-alias-label-primary);
730
+ background: var(--dsw-alias-interactive-bg-hover); }
731
+ /* Reasoning effort: the slider.
732
+ ---------------------------------------------------------------------------
733
+ A thick pill-shaped track with a solid accent fill and a disc riding on it. The
734
+ thick track is what makes the control legible at a glance — a hairline rail with
735
+ a small dot reads as a status indicator, not something you can drag.
736
+
737
+ The fill and the active label take the host's business-primary token — the same
738
+ accent DSH uses for its own interactive highlights — so the control recolours
739
+ with the theme instead of carrying a blue that only suits one of them. */
740
+ /* The knob is the rail's full height (28px). Both ends keep the knob clear of the
741
+ rail's cap, but by DIFFERENT amounts — that asymmetry is the requested behaviour,
742
+ not an oversight:
743
+ --dmp-stop-left 28px the first stop sits a full knob diameter in from the
744
+ left cap, so the knob stops well short of that end;
745
+ --dmp-stop-right 16px the last stop sits just past the knob's radius (14), so a
746
+ hairline of track shows beyond the knob at the top level.
747
+ The right gap is deliberately the same weight as the knob's own ring: at 14 the
748
+ knob landed exactly on the cap and the two outlines met, which read as a smudge
749
+ rather than as a stop. The dots, the labels, the fill and the native input's
750
+ travel all derive from these two numbers, which is why they are variables rather
751
+ than four copies of the same value. */
752
+ .dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
753
+ --dmp-stop-left: 28px; --dmp-stop-right: 16px; }
754
+ /* The accent.
755
+ The alias token is one step too pale on a dark panel: in the dark theme
756
+ --dsw-alias-state-business-primary resolves to the deepseek-400 step, which reads
757
+ as washed out against the panel surface. The ramp's next step down, deepseek-450,
758
+ is the same hue with the weight the bar needs. The alias token stays as the
759
+ fallback so a future ramp change still lands on something.
760
+ The preview pages carry these same values, which is why a preview can look right
761
+ while the app looks pale — the demo used to hard-code a blue of its own. */
762
+ .dmp-slider { --dmp-accent: var(--dsw-static-deepseek-450,
763
+ var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary))); }
764
+ /* Radius is a LITERAL 15px on a 28px-tall rail — deliberately ONE more than half the
765
+ height, so the ends curve a touch harder than a true semicircle and read as round
766
+ rather than as pillowed caps. Anything below half looks square; this is the
767
+ user-tuned value.
768
+ The two numbers are tied: change the height, move the radius with it.
769
+
770
+ clip-path does the actual shaping. A border-radius of 16px on this 32px box was
771
+ measured painting an ~10px corner in the host, so the ends kept reading as a
772
+ rounded rectangle no matter what the declaration said. clip-path: inset(... round
773
+ 17px) is a different property: it clips the element AND its children to the same
774
+ shape, so neither the track nor the fill can be squared off by whatever was
775
+ winning on border-radius. border-radius stays as the fallback. */
776
+ .dmp-slider-rail { position: relative; height: var(--dmp-rail-height);
777
+ margin: 12px 0 4px; border-radius: 15px; flex: none;
778
+ clip-path: inset(0 round 15px);
779
+ background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
780
+ /* The outline: vector, like the knob's ring.
781
+ An inset box-shadow is a raster edge — 1px reads flat and 1.5px falls between
782
+ pixels and smears. This SVG stretches to the rail with preserveAspectRatio none
783
+ and keeps its stroke at exactly 1px through vector-effect, so the line is as
784
+ crisp as the knob's circle. It sits above the fill so it stays visible across the
785
+ blue, and pointer-events none keeps the range input reachable underneath. */
786
+ .dmp-slider-outline { position: absolute; inset: 0; width: 100%; height: 100%;
787
+ pointer-events: none; z-index: 7; overflow: visible; }
788
+ /* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
789
+ stop and 100% the last — the knob's travel, the dots, the labels and the fill all
790
+ read from this one box. */
791
+ .dmp-slider-track { position: absolute; top: 0; bottom: 0;
792
+ left: var(--dmp-stop-left); right: var(--dmp-stop-right); }
793
+ /* The fill starts at the RAIL's left edge and ends on the knob's centre line.
794
+
795
+ Starting it at the stop axis left a grey stub at the left cap, and the rail's
796
+ clip gives that end its shape, so the run is pulled back to the rail's edge.
797
+
798
+ It carries NO radius of its own: it is a plain rectangle clipped by the rail,
799
+ which is what makes its left cap identical to the track's. */
800
+ .dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
801
+ left: calc(var(--dmp-stop-left) * -1);
802
+ width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
803
+ background: var(--dmp-accent);
804
+ z-index: 1; transition: width .15s ease-out; pointer-events: none; }
805
+ .dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
806
+ pointer-events: none; z-index: 2; }
807
+ /* Stops stay SMALL and GREY, and they are only visible AHEAD of the knob: the blue
808
+ fill already says "you are this far", so a dot sitting on it read as dust on the
809
+ colour. Unreached stops keep working as tick marks on the empty part of the track,
810
+ which is where they still carry information. */
811
+ .dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
812
+ border-radius: 50%; transform: translate(-50%, -50%);
813
+ background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
814
+ transition: opacity .15s ease-out; }
815
+ .dmp-slider-dot-lit { opacity: 0; }
816
+ /* The exhaust: a zero-size point pinned to the knob, carrying the particles.
817
+ It sits ABOVE the fill and BELOW the knob, so squares are born behind the disc and
818
+ drift out from under it. Its left offset carries the SAME transition as the knob — that
819
+ is the whole alignment contract: both are driven by --dmp-effort-at, so as long as
820
+ they animate with the same duration and easing the spawn point stays under the
821
+ knob while it travels. (A drag drops the transition on both; see the dragging
822
+ rule below, which is what fixed particles appearing ahead of the knob.) */
823
+ .dmp-emit { position: absolute; top: 50%; left: var(--dmp-effort-at);
824
+ width: 0; height: 0; z-index: 3; pointer-events: none;
825
+ transition: left .15s ease-out; }
826
+ /* One square. The dy0 offset spreads the spawn points down the knob cross-section; the
827
+ extra dy/dx send each one its own way so they do not travel as a block. */
828
+ .dmp-emit-p { position: absolute; left: 0; top: 0;
829
+ width: var(--sz, 4px); height: var(--sz, 4px); border-radius: 1px;
830
+ background: color-mix(in srgb, var(--dsw-alias-label-primary) 92%, transparent);
831
+ box-shadow: 0 0 4px color-mix(in srgb,
832
+ var(--dmp-accent) 80%, transparent);
833
+ opacity: 0; transform: translate(-50%, -50%);
834
+ animation: dmp-emit 1.7s cubic-bezier(.25,.1,.6,1) infinite;
835
+ animation-delay: calc(var(--i) * -106ms); }
836
+ /* The streak left behind the sheet of squares. */
837
+ .dmp-emit::before { content: ''; position: absolute; right: 4px; top: 50%;
838
+ width: 54px; height: 8px; transform: translateY(-50%); border-radius: 6px;
839
+ background: linear-gradient(270deg,
840
+ color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent),
841
+ color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent) 45%,
842
+ transparent);
843
+ filter: blur(2px); animation: dmp-emit-trail 1.7s ease-in-out infinite; }
844
+ @keyframes dmp-emit {
845
+ 0% { transform: translate(-50%, -50%) translate(0, var(--dy0)) scale(1); opacity: 0; }
846
+ 14% { opacity: .95; }
847
+ 70% { opacity: .5; }
848
+ 100% { transform: translate(-50%, -50%)
849
+ translate(var(--dx), calc(var(--dy0) + var(--dy))) scale(.2) rotate(var(--rot)); opacity: 0; } }
850
+ @keyframes dmp-emit-trail { 0%, 100% { opacity: .3; } 50% { opacity: .75; } }
851
+ /* A drag is direct manipulation: the knob, the fill and the emitter all snap to the
852
+ pointer instead of easing, which keeps the particles born exactly at the knob. */
853
+ .dmp-slider-dragging .dmp-slider-thumb,
854
+ .dmp-slider-dragging .dmp-slider-fill,
855
+ .dmp-slider-dragging .dmp-emit { transition: none; }
856
+ /* The knob fills the rail's height and is drawn as an SVG, so no inherited
857
+ border-radius, padding or box-sizing rule can square it off. Its size comes from
858
+ --dmp-thumb-size; the stop offsets and the input's travel are derived from the
859
+ same variable so the three cannot drift apart. */
860
+ .dmp-slider-thumb { position: absolute; top: 50%; left: var(--dmp-effort-at);
861
+ width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
862
+ margin-left: calc(var(--dmp-thumb-size) / -2);
863
+ margin-top: calc(var(--dmp-thumb-size) / -2);
864
+ display: block; overflow: visible; pointer-events: none; z-index: 5;
865
+ /* The knob takes the panel's grey in dark and pure white in light, with a black
866
+ ring in both. The two values swing with the host stylesheet's own media query,
867
+ so no JS theme detection is involved. */
868
+ --dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
869
+ --dmp-thumb-ring: #000;
870
+ filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
871
+ transition: left .15s ease-out, transform .12s ease; }
872
+ .dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
873
+ .dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
874
+ box-shadow: 0 0 0 3px color-mix(in srgb,
875
+ var(--dmp-accent) 35%, transparent); }
876
+ /* The range input spans from the first stop's reach to the rail's right edge, and
877
+ its native thumb is the knob's size. That makes its invisible thumb's centre
878
+ travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
879
+ knob is drawn on. Spanning the full rail instead would start the travel one
880
+ radius short of the first stop, and the pointer would disagree with the knob. */
881
+ .dmp-slider-input { position: absolute; z-index: 6; top: 0;
882
+ left: calc(var(--dmp-stop-left) - var(--dmp-thumb-size) / 2);
883
+ width: calc(100% - var(--dmp-stop-left) + var(--dmp-thumb-size) / 2);
884
+ height: var(--dmp-rail-height); margin: 0; cursor: pointer;
885
+ -webkit-appearance: none; appearance: none; background: transparent;
886
+ /* Two-level invisibility: opacity alone leaves the native thumb able to paint
887
+ through on some engines, which reads as a stray rectangle riding the track. */
888
+ opacity: 0; color: transparent; border: 0; }
889
+ .dmp-slider-input::-webkit-slider-runnable-track { background: transparent; border: 0; }
890
+ /* The native thumb is sized to the painted one so pointer travel matches, then made
891
+ fully transparent: leaving it visible is what produced a square riding the track. */
892
+ .dmp-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
893
+ width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
894
+ background: transparent; border: 0; }
895
+ .dmp-slider-input::-moz-range-thumb { width: var(--dmp-thumb-size); height: var(--dmp-thumb-size); border: 0; }
896
+ .dmp-slider-input:disabled { cursor: default; }
897
+ .dmp-slider[aria-disabled="true"] { opacity: .5; }
898
+ /* The legend shares the track's inset so a label sits under the stop it names. */
899
+ .dmp-slider-legend { position: relative; height: 16px;
900
+ margin: 0 var(--dmp-stop-right) 0 var(--dmp-stop-left); }
901
+ /* The level labels are Latin, so they take Montserrat explicitly — inheriting from
902
+ the panel would pull the platform face back in. */
903
+ .dmp-slider-tick { position: absolute; top: 0; transform: translateX(-50%);
904
+ padding: 1px 4px; border: 0; border-radius: 6px; background: transparent;
905
+ /* One brightness for every unselected label: the tertiary token sat too close to
906
+ the panel background, so the secondary token lifts all of them equally. */
907
+ color: var(--dsw-alias-label-secondary);
908
+ /* 12px, up from 11px: at 11 the stop names sat visibly below the heading's weight
909
+ and were hard to hit by eye under a 32px rail. */
910
+ font-size: 11px; line-height: 15px;
911
+ font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
912
+ white-space: nowrap; cursor: pointer;
913
+ max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
914
+ /* Every label is centred on its stop.
915
+ The two end labels used to be re-anchored instead (no transform at the start, a full
916
+ translate at the end) so their text could not overflow the legend. That kept them
917
+ inside the box, but it also pulled them off their dots: the last label sat entirely
918
+ to the LEFT of its stop, which is exactly what makes "Max" look misaligned against
919
+ the dot above it. The legend already spans the stop axis and the labels are short,
920
+ so plain centring fits — and every label now shares one rule. */
921
+ .dmp-slider-tick[data-edge="start"] { transform: translateX(-50%); margin-left: 0; }
922
+ .dmp-slider-tick[data-edge="end"] { transform: translateX(-50%); margin-left: 0; }
923
+ .dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
924
+ background: var(--dsw-alias-interactive-bg-hover); }
925
+ .dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
926
+ .dmp-slider-tick:disabled { cursor: default; }
927
+ /* The active label is marked by COLOUR only, not weight.
928
+ Bolding it made the selected label measurably wider and taller-looking than its
929
+ neighbours, so the labels appeared to change size as the level moved. Keeping one
930
+ weight keeps every label the same box; the accent colour carries the selection. */
931
+ .dmp-slider-tick-active { color: var(--dmp-accent); }
932
+ .dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
933
+ border-top: 1px solid var(--dsw-alias-border-l1);
934
+ font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
935
+ /* Light surfaces need a softer knob: a black ring reads as a hard edge on a
936
+ near-white panel, so the light theme pulls the ring to the host's border colour
937
+ and keeps the fill pure white. The two values swing with the host stylesheet's
938
+ own media query, so no JS theme detection is involved. */
939
+ @media (prefers-color-scheme: light) {
940
+ .dmp-slider-thumb {
941
+ --dmp-thumb-fill: var(--dsw-alias-bg-base);
942
+ --dmp-thumb-ring: var(--dsw-alias-border-l2);
943
+ }
944
+ }
945
+ @media (prefers-reduced-motion: reduce) {
946
+ .dmp-slider-fill, .dmp-slider-thumb { transition: none; }
947
+ /* The exhaust is decoration: with motion turned down it is simply not drawn. */
948
+ .dmp-emit { display: none; }
949
+ }
950
+ /* ---------------------------------------------------------------------------
951
+ Rejected-selection notice
952
+ ---------------------------------------------------------------------------
953
+ A failed switch is reported ABOVE the panels, never inside them: the list and
954
+ the slider are for choosing, and a raw "code: message" dumped among the rows
955
+ both crowds the list and scrolls away. Collapsed it is one quiet line; the
956
+ detail is opt-in.
957
+
958
+ It is absolutely positioned above the row so it takes no width from the
959
+ composer, and --dmp-notice-lift (measured in ModelPicker) is what raises an
960
+ open panel over its measured height instead of on top of it. */
961
+ .dmp-notice { position: absolute; bottom: calc(100% + 6px); left: 0; right: auto;
962
+ z-index: 1100; display: flex; flex-direction: column; align-items: flex-start;
963
+ width: max-content; max-width: min(360px, 92vw); box-sizing: border-box;
964
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px);
965
+ background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
966
+ box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv2));
967
+ overflow: hidden; }
968
+ .dmp-notice-toggle { display: flex; align-items: center; gap: 6px; width: 100%;
969
+ box-sizing: border-box; padding: 6px 8px; border: 0; background: transparent;
970
+ color: var(--dsw-alias-state-error-primary); font: inherit; font-size: 12px;
971
+ line-height: 18px; text-align: left; cursor: pointer; }
972
+ .dmp-notice-toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
973
+ .dmp-notice-toggle:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: -2px; }
974
+ .dmp-notice-toggle .dmp-chevron { margin-left: auto; }
975
+ .dmp-notice-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
976
+ /* The raw provider error: monospaced so a "code: message" pair is readable, and
977
+ selectable so it can be copied into a bug report. */
978
+ .dmp-notice-detail { width: 100%; box-sizing: border-box; padding: 6px 8px;
979
+ border-top: 1px solid var(--dsw-alias-border-l1);
980
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
981
+ font-size: 11.5px; line-height: 16px; white-space: pre-wrap; word-break: break-word;
982
+ color: var(--dsw-alias-label-secondary); user-select: text; }`
619
983
 
620
984
  //# module: src/ui/dict.js
621
985
  /**
@@ -624,35 +988,36 @@ const CSS = `
624
988
  * Kept as a plain object rather than the Client locale service because the
625
989
  * strings are this component's own copy, not a namespace other plugins
626
990
  * translate. Adding a language is one more entry here.
991
+ *
992
+ * Only keys the component actually reads belong here: an entry nobody looks up
993
+ * is a string that silently rots, and a reviewer cannot tell it is dead.
627
994
  */
628
995
  const DICT = {
629
996
  'zh-CN': {
630
997
  placeholder: '搜索模型',
631
998
  empty: '没有匹配的模型',
632
999
  loading: '正在读取模型列表…',
633
- failed: '读取模型列表失败',
634
1000
  retry: '重试',
635
1001
  noCatalog: '当前没有可用模型',
636
1002
  trigger: (name) => `${name}(点此切换模型)`,
637
1003
  effortTitle: (name) => `思考档位:${name}(点此调整)`,
1004
+ effortHeading: '思考强度',
1005
+ effortHint: '更高的强度通常需要更多思考时间。',
638
1006
  switching: '切换中…',
639
- switchFailed: '切换失败',
640
- providerDefault: '默认',
641
- groupFailed: (name) => `${name} 加载失败`
1007
+ switchFailed: '切换失败'
642
1008
  },
643
1009
  en: {
644
1010
  placeholder: 'Search models',
645
1011
  empty: 'No matching model',
646
1012
  loading: 'Loading models…',
647
- failed: 'Could not load the model list',
648
1013
  retry: 'Retry',
649
1014
  noCatalog: 'No model is currently available',
650
1015
  trigger: (name) => `${name} (click to switch model)`,
651
1016
  effortTitle: (name) => `Reasoning level: ${name} (click to change)`,
1017
+ effortHeading: 'Reasoning effort',
1018
+ effortHint: 'Higher effort usually takes more time to think.',
652
1019
  switching: 'Switching…',
653
- switchFailed: 'Switch failed',
654
- providerDefault: 'Default',
655
- groupFailed: (name) => `${name} failed to load`
1020
+ switchFailed: 'Switch failed'
656
1021
  }
657
1022
  }
658
1023
 
@@ -671,7 +1036,27 @@ function dictionaryFor(localeId) {
671
1036
 
672
1037
 
673
1038
 
674
- const LABEL_SLOT = 'conversation.input.model.label'
1039
+
1040
+
1041
+ /**
1042
+ * Turn a failed `select` result into the two strings the notice shows.
1043
+ *
1044
+ * `ModelDirectory.select` resolves to `{ ok: false, error: { code, message } }`
1045
+ * on a rejected selection. The summary is the short, always-visible line; the
1046
+ * detail is what expanding the notice reveals, and it is the only place the
1047
+ * provider's raw error text appears — the panels themselves stay free of it.
1048
+ *
1049
+ * @param result - the value `select` resolved to, possibly undefined.
1050
+ * @param t - the active dictionary.
1051
+ * @returns `{ summary, detail }`.
1052
+ */
1053
+ function failureOf(result, t) {
1054
+ const error = result?.error
1055
+ if (error === undefined || error === null) return { summary: t.switchFailed, detail: '' }
1056
+ const code = typeof error.code === 'string' ? error.code : ''
1057
+ const message = typeof error.message === 'string' ? error.message : String(error)
1058
+ return { summary: t.switchFailed, detail: code.length > 0 ? `${code}: ${message}` : message }
1059
+ }
675
1060
 
676
1061
  /**
677
1062
  * The composer's model seat: a searchable, provider-grouped picker plus a
@@ -681,59 +1066,114 @@ const LABEL_SLOT = 'conversation.input.model.label'
681
1066
  * `current` (the effective selection, projection or default), `routable`
682
1067
  * (whether the Host would still accept it), `pending` (the selection being
683
1068
  * 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
1069
  *
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.
1070
+ * @param React - the React runtime.
1071
+ * @returns the slot component.
690
1072
  */
691
1073
  function createModelPicker(React) {
692
1074
  const h = React.createElement
1075
+ const EffortSlider = createEffortSlider(React)
693
1076
  const { useCallback, useEffect, useMemo, useRef, useState } = React
694
1077
 
1078
+ /**
1079
+ * Subscribe to any `{ getSnapshot, subscribe }` source.
1080
+ *
1081
+ * Both sources this component reads — the session directory and the locale
1082
+ * service — expose exactly that pair, so one hook covers them. It lives here
1083
+ * rather than in a shared module because it needs the component's own hooks,
1084
+ * which are bound to this factory's React instance.
1085
+ *
1086
+ * @param source - the observable source, or undefined while it is not resolved.
1087
+ * @returns the current snapshot.
1088
+ */
1089
+ const useSnapshot = (source) => {
1090
+ const [value, setValue] = useState(() => source?.getSnapshot?.())
1091
+ useEffect(() => {
1092
+ if (source === undefined || source === null) return undefined
1093
+ const update = () => setValue(source.getSnapshot())
1094
+ update()
1095
+ return source.subscribe(update)
1096
+ }, [source])
1097
+ return value
1098
+ }
1099
+
695
1100
  return function ModelPicker(props) {
696
- const { sessionId, locked, renderSlot, directory, catalog: catalogFace, available } = props
1101
+ /**
1102
+ * The props bundle the shipped registration hands its own component.
1103
+ *
1104
+ * `directory` is the session directory's STORE (already subscribed-ready),
1105
+ * `load` asks it to refresh, `select` submits a triple, and `available` says
1106
+ * whether this session may change models at all. Nothing here reaches behind
1107
+ * that bundle: the data layer is the shipped one, this file is the view.
1108
+ */
1109
+ const { locked, directory, available, load, select: submit } = props
697
1110
 
698
- const localeId = typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
1111
+ /**
1112
+ * The active language, followed live.
1113
+ *
1114
+ * `localeSource` is the locale service itself (`getSnapshot` / `subscribe`),
1115
+ * so a language switch made while the composer is on screen re-renders with
1116
+ * the new dictionary. `localeId` is the value resolved when the seat was
1117
+ * registered, used only until the first snapshot arrives.
1118
+ */
1119
+ const localeState = useSnapshot(props.localeSource)
1120
+ const localeId = typeof localeState?.active === 'string' && localeState.active.length > 0
1121
+ ? localeState.active
1122
+ : typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
699
1123
  const t = dictionaryFor(localeId)
1124
+ // The panel heading is vector art drawn from CJK outlines, so it only reads
1125
+ // as words in Chinese; every other language prints the plain string.
1126
+ const glyphCaption = String(localeId).toLowerCase().startsWith('zh')
700
1127
 
701
1128
  // One panel at a time: the model list and the effort list are separate
702
1129
  // controls, and opening either closes the other.
703
1130
  const [panel, setPanel] = useState(null)
704
1131
  const [query, setQuery] = useState('')
1132
+ // The level the thumb should sit on right now, before the Host confirms.
1133
+ // Null means "follow the directory".
1134
+ const [draftEffort, setDraftEffort] = useState(null)
1135
+ // A rejected selection. Kept out of the panels on purpose: the notice above
1136
+ // them is collapsed until the user asks for the raw error.
1137
+ const [failure, setFailure] = useState(null)
1138
+ const [noticeOpen, setNoticeOpen] = useState(false)
1139
+ const [noticeLift, setNoticeLift] = useState(0)
705
1140
  const rootRef = useRef(null)
1141
+ const noticeRef = useRef(null)
1142
+ // Whether the most recent action was a load or a selection. `state.error`
1143
+ // carries both, and only a LOAD failure belongs inside the model panel.
1144
+ const lastActionRef = useRef('load')
706
1145
 
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)
1146
+ const state = useSnapshot(directory) ?? {}
712
1147
  const groups = Array.isArray(state.groups) ? state.groups : []
713
1148
  const failures = Array.isArray(state.failures) ? state.failures : []
714
1149
  const selection = state.current ?? null
715
1150
  const pending = state.pending ?? null
716
- const busy = pending !== null || state.status === 'selecting'
1151
+ /**
1152
+ * Distinguish a MODEL change from a LEVEL change.
1153
+ *
1154
+ * The directory reports one `selecting` status for both, and `pending` holds
1155
+ * whichever triple is in flight. They are not the same event to the user: a
1156
+ * level change is a dial, dragged back and forth, and each stop submits. So a
1157
+ * pending selection is treated as a model change ONLY when it names a
1158
+ * different model than the one in effect — otherwise every slider stop made
1159
+ * the model trigger print "Switching…" and go dim, which read as the model
1160
+ * button flickering on its own.
1161
+ */
1162
+ const modelKeyOf = (value) => value === null ? null : `${value.provider ?? ''}:${value.model ?? ''}`
1163
+ const swappingModel = pending !== null && modelKeyOf(pending) !== modelKeyOf(selection)
1164
+ const busy = swappingModel
1165
+
1166
+ const doLoad = useCallback(() => {
1167
+ lastActionRef.current = 'load'
1168
+ // Ask the directory to load. It is idempotent: the shipped controller
1169
+ // shares one in-flight request and re-projects when it settles.
1170
+ if (typeof load === 'function') load()
1171
+ }, [load])
717
1172
 
718
1173
  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
- }
1174
+ doLoad()
735
1175
  return undefined
736
- }, [directory, catalogFace])
1176
+ }, [doLoad])
737
1177
 
738
1178
  useEffect(() => {
739
1179
  if (panel === null) return undefined
@@ -749,38 +1189,116 @@ function createModelPicker(React) {
749
1189
  }
750
1190
  }, [panel])
751
1191
 
1192
+ // Closing a panel also clears the transient state it carried: the search
1193
+ // text (so reopening shows the full list) and the failure notice.
1194
+ useEffect(() => {
1195
+ if (panel !== null) return
1196
+ setQuery('')
1197
+ setFailure(null)
1198
+ setNoticeOpen(false)
1199
+ }, [panel])
1200
+
1201
+ /**
1202
+ * Push a failure into the notice and collapse it again.
1203
+ *
1204
+ * @param result - the failed `select` result.
1205
+ */
1206
+ const showFailure = useCallback((result) => {
1207
+ setFailure(failureOf(result, t))
1208
+ setNoticeOpen(false)
1209
+ }, [t])
1210
+
752
1211
  /**
753
1212
  * 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.
1213
+ * not name one — the shipped picker sends the same triple.
1214
+ *
1215
+ * Returns the directory's own result unchanged: `{ ok: true }` or
1216
+ * `{ ok: false, error }`. Anything else would make a successful pick look
1217
+ * like a failure and leave the panel open.
756
1218
  */
757
1219
  const select = useCallback(async (group, model, reasoningEffort) => {
758
- if (directory === undefined || directory === null) return
1220
+ if (typeof submit !== 'function') return undefined
1221
+ lastActionRef.current = 'select'
759
1222
  const effort = reasoningEffort ?? model?.reasoning?.defaultEffort
760
- const result = await directory.select({
1223
+ return submit({
761
1224
  provider: group.id,
762
1225
  model: model.id,
763
1226
  ...effort === undefined ? {} : { reasoningEffort: effort }
764
1227
  })
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])
1228
+ }, [submit])
1229
+
1230
+ /**
1231
+ * Choosing a MODEL closes the panel on success.
1232
+ *
1233
+ * A rejected selection keeps it open and raises the notice instead, so the
1234
+ * user can pick another model without reopening the list.
1235
+ */
1236
+ const chooseModel = useCallback(async (group, model, reasoningEffort) => {
1237
+ const result = await select(group, model, reasoningEffort)
1238
+ if (result?.ok === true) {
1239
+ setFailure(null)
1240
+ setPanel(null)
1241
+ return
1242
+ }
1243
+ showFailure(result)
1244
+ }, [select, showFailure])
1245
+
1246
+ const queryText = query.trim()
1247
+ /**
1248
+ * The model list, ranked exactly like the shipped menus.
1249
+ *
1250
+ * `rankByName` is the host's own fuzzy matcher: an ordered subsequence of
1251
+ * the name (or of the model id, supplied as the second search key), prefix
1252
+ * hits first. A plain substring test could not find `gpt61` in
1253
+ * `GPT-6.1-Sol`, which is most of the point of having a search box.
1254
+ */
1255
+ const filtered = useMemo(() => groups
1256
+ .map((group) => ({
1257
+ group,
1258
+ models: rankByName(
1259
+ (group.models ?? []).map((model) => ({
1260
+ ...model,
1261
+ label: typeof model.label === 'string' ? model.label : model.id
1262
+ })),
1263
+ queryText
1264
+ )
1265
+ }))
1266
+ .filter((entry) => entry.models.length > 0), [groups, queryText])
778
1267
 
779
1268
  const current = selection === null ? null : groups
780
1269
  .find((group) => group.id === selection.provider)?.models
781
1270
  ?.find((model) => model.id === selection.model)
782
1271
  const currentGroup = selection === null ? null : groups.find((group) => group.id === selection.provider) ?? null
783
1272
 
1273
+ /**
1274
+ * A level change keeps the panel open, and moves the thumb IMMEDIATELY.
1275
+ *
1276
+ * Two things were wrong before:
1277
+ *
1278
+ * - Closing the panel on success made it vanish mid-drag, so the animation
1279
+ * never played — a level is a dial, not a commitment.
1280
+ * - The thumb was positioned from the directory's `current`, which only
1281
+ * updates after the Host answers. During a drag that means the thumb is
1282
+ * dragged back to the old stop on every render — it looks stuck to the
1283
+ * start and refuses to follow the pointer.
1284
+ *
1285
+ * `draftEffort` is the optimistic stop: set it on every move so the thumb
1286
+ * tracks the pointer, and clear it once the Host confirms so the directory
1287
+ * becomes the source of truth again. If the RPC fails the draft is dropped,
1288
+ * the thumb springs back to the real value, and the notice says why.
1289
+ */
1290
+ const chooseEffort = useCallback(async (effortId) => {
1291
+ if (currentGroup === null || current === null) return
1292
+ setDraftEffort(effortId)
1293
+ const result = await select(currentGroup, current, effortId)
1294
+ if (result?.ok === true) {
1295
+ setFailure(null)
1296
+ return
1297
+ }
1298
+ setDraftEffort(null)
1299
+ showFailure(result)
1300
+ }, [select, currentGroup, current, showFailure])
1301
+
784
1302
  /**
785
1303
  * What the trigger says, in the shipped picker's precedence.
786
1304
  *
@@ -802,25 +1320,79 @@ function createModelPicker(React) {
802
1320
  * will use, so that is what the control shows rather than a blank.
803
1321
  */
804
1322
  const efforts = Array.isArray(current?.reasoning?.efforts) ? current.reasoning.efforts : []
805
- const activeEffortId = selection?.reasoningEffort ?? current?.reasoning?.defaultEffort
1323
+ /**
1324
+ * The level to show, and why it is NOT simply `selection.reasoningEffort`.
1325
+ *
1326
+ * `selection.reasoningEffort` belongs to the SESSION, not to the model. When
1327
+ * the user switches to a model they have never used, that field still carries
1328
+ * the previous model's level — `xhigh` while the new model only declares
1329
+ * `low/high`, or `high` for both, where inheriting it silently starts the new
1330
+ * model at a level the user never chose for it.
1331
+ *
1332
+ * The rule is therefore: a level is only "current" for a model that has
1333
+ * already been used in this session. On a model switch the adapter's declared
1334
+ * default takes over, which is what the Host would use anyway. `modelKey`
1335
+ * tracks which model the level belongs to, so a switch is detectable.
1336
+ */
1337
+ const declares = (id) => id !== undefined && id !== null && efforts.some((effort) => effort.id === id)
1338
+ const modelKey = current === null ? null : `${selection?.provider ?? ''}:${selection?.model ?? ''}`
1339
+ const seenModelRef = useRef(null)
1340
+ const switchedModel = seenModelRef.current !== null && seenModelRef.current !== modelKey
1341
+ const inheritedEffortId = switchedModel ? undefined : selection?.reasoningEffort
1342
+ /**
1343
+ * The level in effect, resolved through three fallbacks.
1344
+ *
1345
+ * A provider may declare `reasoningEfforts` without a default — the
1346
+ * `openai-responses` models in this profile do exactly that (five levels, no
1347
+ * default). Taking `defaultEffort` verbatim then left `activeEffortId`
1348
+ * undefined, so the tag rendered with an empty label even though the slider
1349
+ * had five stops. The first declared level is the right fallback: it is what
1350
+ * an unspecified request uses.
1351
+ */
1352
+ const confirmedEffortId = declares(inheritedEffortId)
1353
+ ? inheritedEffortId
1354
+ : declares(current?.reasoning?.defaultEffort)
1355
+ ? current.reasoning.defaultEffort
1356
+ : efforts[0]?.id
1357
+ useEffect(() => { seenModelRef.current = modelKey }, [modelKey])
1358
+ const activeEffortId = draftEffort !== null && declares(draftEffort)
1359
+ ? draftEffort
1360
+ : confirmedEffortId
806
1361
  const activeEffort = efforts.find((effort) => effort.id === activeEffortId) ?? null
807
- const effortName = (effort) => effort?.name ?? effort?.id ?? String(activeEffortId ?? '')
1362
+
1363
+ /**
1364
+ * What the trigger prints for the current level.
1365
+ *
1366
+ * The trigger is only rendered when the model declares levels, so a resolved
1367
+ * name always exists by then; the id is a fallback for a level whose entry has
1368
+ * no display name, and an empty string is the last resort.
1369
+ */
1370
+ const effortLabel = activeEffort?.name ?? activeEffort?.id ?? String(activeEffortId ?? '')
1371
+
1372
+ /**
1373
+ * Drop the draft once the Host agrees with it.
1374
+ *
1375
+ * Until the directory catches up the draft IS the truth, so it must not be
1376
+ * cleared earlier or the thumb would jump back for one frame. When the
1377
+ * confirmed value finally matches, the draft has done its job.
1378
+ */
1379
+ useEffect(() => {
1380
+ if (draftEffort !== null && confirmedEffortId === draftEffort) setDraftEffort(null)
1381
+ }, [draftEffort, confirmedEffortId])
808
1382
 
809
1383
  /**
810
1384
  * The label, in the shipped picker's addressable wrapper.
811
1385
  *
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.
1386
+ * The `data-provider-id` / `data-model-id` span is kept because host CSS that
1387
+ * targets one model by id still works. It is deliberately plain text: this entry
1388
+ * does not declare the `conversation.input.model.label` child, so the renderer
1389
+ * hands it no `renderSlot` and provider badges cannot mount here (see AGENTS.md,
1390
+ * invariant 2 — declaring that child breaks the whole registration).
817
1391
  */
818
- const renderLabel = (group, model, placement, selected) => h('span', {
1392
+ const renderLabel = (group, model) => h('span', {
819
1393
  'data-provider-id': group?.id,
820
1394
  'data-model-id': model?.id
821
- }, renderSlot === undefined
822
- ? model?.name
823
- : renderSlot(LABEL_SLOT, { provider: group, model, placement, selected }, { fallback: model?.name }))
1395
+ }, model?.name)
824
1396
 
825
1397
  const renderItem = (group, model) => {
826
1398
  const isCurrent = selection !== null &&
@@ -831,9 +1403,9 @@ function createModelPicker(React) {
831
1403
  role: 'option',
832
1404
  'aria-selected': isCurrent,
833
1405
  className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
834
- onClick: () => void select(group, model)
1406
+ onClick: () => void chooseModel(group, model)
835
1407
  },
836
- h('span', { className: 'dmp-item-name' }, renderLabel(group, model, 'option', isCurrent)))
1408
+ h('span', { className: 'dmp-item-name' }, renderLabel(group, model)))
837
1409
  }
838
1410
 
839
1411
  const renderGroup = ({ group, models }) => h('div', { key: group.id },
@@ -842,24 +1414,23 @@ function createModelPicker(React) {
842
1414
 
843
1415
  const notes = []
844
1416
  /**
845
- * Three independent problems, reported separately.
1417
+ * Catalogue problems only.
846
1418
  *
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.
1419
+ * A selection failure is reported by the notice above the panels instead: it
1420
+ * belongs to this session, and the raw `code: message` text is not worth
1421
+ * pushing into the list. `lastActionRef` keeps a selection error from
1422
+ * appearing here as if it were a load failure.
852
1423
  */
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) {
1424
+ const loadError = state.status === 'error' && lastActionRef.current === 'load' &&
1425
+ typeof state.error === 'string' && state.error.length > 0 ? state.error : ''
1426
+ if (state.status === 'loading' && groups.length === 0) {
856
1427
  notes.push(h('div', { key: 'loading', className: 'dmp-note' }, t.loading))
857
1428
  }
858
- if (catalogError.length > 0 || selectionError.length > 0) {
1429
+ if (loadError.length > 0) {
859
1430
  notes.push(h('div', { key: 'error', className: 'dmp-note dmp-note-error' },
860
- catalogError.length > 0 ? catalogError : selectionError,
1431
+ loadError,
861
1432
  h('div', null, h('button', {
862
- type: 'button', className: 'dmp-retry', onClick: () => catalogFace?.refresh()
1433
+ type: 'button', className: 'dmp-retry', onClick: doLoad
863
1434
  }, t.retry))))
864
1435
  }
865
1436
  // A provider group whose catalogue failed: say so, instead of leaving a
@@ -873,40 +1444,124 @@ function createModelPicker(React) {
873
1444
  notes.push(h('div', { key: 'empty', className: 'dmp-note' }, t.empty))
874
1445
  }
875
1446
 
876
- const effortButton = current === null || currentGroup === null || efforts.length === 0
1447
+ const hasEfforts = efforts.length > 0
1448
+
1449
+ /**
1450
+ * Where each popover sits.
1451
+ *
1452
+ * Both panels are wider than their trigger and open above it, so a fixed side
1453
+ * anchor drifts away from the button and runs off the window near an edge.
1454
+ * The shared hook centres each panel on its trigger and clamps the result to
1455
+ * the viewport; `deps` re-measures when the content width changes (a five-level
1456
+ * slider is wider than a three-level one, and a longer model list is wider than
1457
+ * a short one).
1458
+ */
1459
+ const effortAnchor = createAnchoredPanel(React, {
1460
+ open: panel === 'effort',
1461
+ fallbackWidth: 280,
1462
+ deps: [efforts.length]
1463
+ })
1464
+ const modelAnchor = createAnchoredPanel(React, {
1465
+ open: panel === 'model',
1466
+ fallbackWidth: 320,
1467
+ deps: [filtered.length, groups.length]
1468
+ })
1469
+
1470
+ /**
1471
+ * Measure the notice so the open panel can sit above it instead of under it.
1472
+ *
1473
+ * The panel's `bottom` adds `--dmp-notice-lift`, so this only has to report
1474
+ * the notice's rendered height.
1475
+ */
1476
+ useEffect(() => {
1477
+ const node = noticeRef.current
1478
+ if (failure === null || node === null || typeof node.getBoundingClientRect !== 'function') {
1479
+ setNoticeLift(0)
1480
+ return
1481
+ }
1482
+ const height = node.getBoundingClientRect().height
1483
+ setNoticeLift(Number.isFinite(height) && height > 0 ? height + 6 : 0)
1484
+ }, [failure, noticeOpen])
1485
+
1486
+ const notice = failure === null
1487
+ ? null
1488
+ : h('div', { className: 'dmp-notice', ref: noticeRef, role: 'status' },
1489
+ h('button', {
1490
+ type: 'button',
1491
+ className: 'dmp-notice-toggle',
1492
+ 'aria-expanded': noticeOpen,
1493
+ title: failure.detail,
1494
+ onClick: () => setNoticeOpen((value) => !value)
1495
+ },
1496
+ h('span', { className: 'dmp-notice-text' }, failure.summary),
1497
+ h(Chevron, { h, open: noticeOpen })),
1498
+ noticeOpen && failure.detail.length > 0
1499
+ ? h('div', { className: 'dmp-notice-detail' }, failure.detail)
1500
+ : null)
1501
+
1502
+ const effortButton = current === null || currentGroup === null || !hasEfforts
877
1503
  ? null
878
1504
  : h('button', {
879
1505
  type: 'button',
1506
+ ref: effortAnchor.ref,
880
1507
  className: 'dmp-trigger dmp-trigger-effort',
881
- disabled: locked === true || busy,
882
- title: t.effortTitle(effortName(activeEffort)),
883
- 'aria-haspopup': 'listbox',
1508
+ // Only a locked composer disables the level tag. Its own commits flip the
1509
+ // directory to `selecting`, and disabling here killed the drag after the
1510
+ // first stop — the same reason the slider's range input stays enabled.
1511
+ disabled: locked === true,
1512
+ title: t.effortTitle(effortLabel),
1513
+ 'aria-haspopup': 'dialog',
884
1514
  'aria-expanded': panel === 'effort',
885
1515
  onClick: () => setPanel((value) => (value === 'effort' ? null : 'effort'))
886
1516
  },
887
- h('span', { className: 'dmp-trigger-text' }, effortName(activeEffort)),
1517
+ h('span', { className: 'dmp-trigger-text dmp-trigger-effort-text' }, effortLabel),
888
1518
  h(Chevron, { h, open: panel === 'effort' }))
889
1519
 
890
1520
  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
- })))
1521
+ ? h('div', {
1522
+ className: 'dmp-panel dmp-panel-slider',
1523
+ role: 'dialog',
1524
+ 'aria-label': t.effortHeading,
1525
+ // The host lifts the composer's stacking context when one of its own menus
1526
+ // is open: `.composerSeat:has([data-trigger-menu]) { z-index: 9 }`. Without
1527
+ // it the seat stays at z-index 7, and the chat's "jump to latest" button
1528
+ // (z-index 8, a sibling of the seat) paints over this panel no matter what
1529
+ // z-index the panel itself asks for — a child cannot escape its parent's
1530
+ // stacking context. Marking the panel is the supported way to ask for the
1531
+ // same lift.
1532
+ 'data-trigger-menu': '',
1533
+ ref: effortAnchor.panelRef,
1534
+ style: effortAnchor.style
1535
+ },
1536
+ h(EffortSlider, {
1537
+ efforts,
1538
+ activeId: activeEffortId,
1539
+ /**
1540
+ * Deliberately NOT disabled while a change is settling.
1541
+ *
1542
+ * `busy` tracks the directory's `status === 'selecting'`, and every stop
1543
+ * the slider passes submits a selection — so the first stop flipped
1544
+ * `busy`, disabled the input mid-gesture, and the drag died. The user
1545
+ * then had to press again for each further stop. A level is a dial: it
1546
+ * stays live, and the draft value keeps the thumb honest while the Host
1547
+ * catches up.
1548
+ */
1549
+ disabled: locked === true,
1550
+ caption: glyphCaption ? undefined : t.effortHeading,
1551
+ t,
1552
+ onChange: (effortId) => { void chooseEffort(effortId) }
1553
+ }))
906
1554
  : null
907
1555
 
908
1556
  const modelPanel = panel === 'model'
909
- ? h('div', { className: 'dmp-panel', role: 'listbox' },
1557
+ ? h('div', {
1558
+ className: 'dmp-panel',
1559
+ role: 'listbox',
1560
+ // Same stacking lift as the effort panel: see the comment there.
1561
+ 'data-trigger-menu': '',
1562
+ ref: modelAnchor.panelRef,
1563
+ style: modelAnchor.style
1564
+ },
910
1565
  h('div', { className: 'dmp-search' },
911
1566
  h('input', {
912
1567
  type: 'search',
@@ -928,7 +1583,11 @@ function createModelPicker(React) {
928
1583
  */
929
1584
  if (available === false) return null
930
1585
 
931
- return h('div', { className: 'dmp-root', ref: rootRef },
1586
+ return h('div', {
1587
+ className: 'dmp-root',
1588
+ ref: rootRef,
1589
+ style: noticeLift > 0 ? { '--dmp-notice-lift': `${noticeLift}px` } : undefined
1590
+ },
932
1591
  h('style', null, CSS),
933
1592
  // The model control and the reasoning-level control are deliberately two
934
1593
  // buttons: they are two independent choices, and a model change must not
@@ -936,6 +1595,7 @@ function createModelPicker(React) {
936
1595
  h('div', { className: 'dmp-control' },
937
1596
  h('button', {
938
1597
  type: 'button',
1598
+ ref: modelAnchor.ref,
939
1599
  className: 'dmp-trigger',
940
1600
  disabled: locked === true || busy,
941
1601
  title: t.trigger(currentLabel),
@@ -948,10 +1608,11 @@ function createModelPicker(React) {
948
1608
  ? t.switching
949
1609
  : (current === null || currentGroup === null
950
1610
  ? currentLabel
951
- : renderLabel(currentGroup, current, 'trigger', true))),
1611
+ : renderLabel(currentGroup, current))),
952
1612
  h(Chevron, { h, open: panel === 'model' })),
953
1613
  modelPanel),
954
- effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel))
1614
+ effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel),
1615
+ notice)
955
1616
  }
956
1617
  }
957
1618
 
@@ -959,40 +1620,34 @@ function createModelPicker(React) {
959
1620
  /**
960
1621
  * Client entry: the module-loader registration and the plugin object.
961
1622
  *
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`.
1623
+ * This plugin replaces ONLY the composer's model-selection UI. Everything behind
1624
+ * it — the per-session directory, the catalogue cache, the invalidation events,
1625
+ * the selection RPC — is the shipped `ui-model-selection` implementation, reached
1626
+ * through its `modelDirectories` service.
965
1627
  *
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.
1628
+ * That split works because the shipped plugin keeps its slot registration and its
1629
+ * component apart: the registration's `inject` hands the component a plain props
1630
+ * bundle (`available`, a `directory` store, `load`, `select`), and the component
1631
+ * may render however it likes. This entry registers the same slot with the same
1632
+ * `inject` contract and substitutes its own component.
970
1633
  *
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.
1634
+ * Why not copy the shipped bundle instead: a copy registers a second
1635
+ * `ModelDirectoryResolver` under the same service name, the two fight over it,
1636
+ * and the shipped plugin parks in `loading` — which fails the whole renderer
1637
+ * boot. Reusing the service avoids that, and keeps tracking upstream fixes.
979
1638
  */
980
1639
 
981
-
982
-
983
-
984
1640
  const PLUGIN = 'dsh-model-picker'
985
1641
  const SLOT = 'conversation.input.model'
986
- /** The shipped service that owns per-session model directories. */
1642
+ /** The service the shipped picker exposes for per-session model directories. */
987
1643
  const MODEL_DIRECTORIES = 'modelDirectories'
988
1644
  /**
989
1645
  * BELOW the shipped occupant's `priority: 0`.
990
1646
  *
991
1647
  * For a `single` slot the LOWEST priority renders — the registry says so in its
992
1648
  * 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.
1649
+ * renders)"). A larger number would lose to the shipped picker instead of
1650
+ * shadowing it.
996
1651
  */
997
1652
  const SHADOW_PRIORITY = -1
998
1653
 
@@ -1001,17 +1656,12 @@ const SHADOW_PRIORITY = -1
1001
1656
  *
1002
1657
  * The packaged desktop app has no devtools, so a client half that fails to
1003
1658
  * 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.
1659
+ * to the Host half, which logs it beside its own line: the pair tells the two
1660
+ * cases apart immediately.
1006
1661
  *
1007
1662
  * @param stage - a short description of the load stage reached.
1008
1663
  */
1009
1664
  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
1665
  try {
1016
1666
  console.error(`[model-picker] ${stage}`)
1017
1667
  } catch {
@@ -1032,8 +1682,7 @@ const report = (stage) => {
1032
1682
  /**
1033
1683
  * The failure worth capturing happens *outside* apply: the client composition
1034
1684
  * 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.
1685
+ * way is only visible in the page.
1037
1686
  *
1038
1687
  * @param value - the thrown value, in any shape.
1039
1688
  * @returns a short description safe to log.
@@ -1070,184 +1719,153 @@ window.__ModuleLoader__.load({
1070
1719
  return {
1071
1720
  name: PLUGIN,
1072
1721
  /**
1073
- * No fiber-level `inject`.
1722
+ * The services this half reads, PLUS the ones the shipped directory service
1723
+ * reads on its own behalf.
1724
+ *
1725
+ * `remote` and `remote.session` look unused here — this file never mentions
1726
+ * them — but `models.directoryFor(sessionId)` calls into the shipped
1727
+ * resolver, and that call runs in THIS entry's fiber. Cordis only exposes a
1728
+ * service to a fiber that declares it, so dropping them makes every
1729
+ * `directoryFor` throw
1074
1730
  *
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.
1731
+ * cannot get property "remote.session" without inject
1732
+ *
1733
+ * from inside `inject`. The renderer catches that, retires the entry and
1734
+ * silently paints the shipped picker — the plugin "just disappears".
1735
+ * Declaring what the callee needs is part of calling it, so they stay.
1080
1736
  */
1737
+ inject: ['locale', 'sessions', 'slots', 'remote', 'remote.session'],
1081
1738
  apply(ctx) {
1082
1739
  report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
1083
1740
 
1084
1741
  /**
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.
1742
+ * Register the seat through the shipped directory service.
1109
1743
  *
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.
1744
+ * `ctx.inject` runs this callback once the named services are visible, so
1745
+ * the registration lands at the same point the shipped one would. It is
1746
+ * also re-run if a service goes away and returns, which is what keeps the
1747
+ * seat from binding to `undefined` across a reconnect.
1114
1748
  */
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() })
1749
+ // `remote` / `remote.session` are declared here as well as on the plugin:
1750
+ // this callback runs in the fiber that `ctx.inject` creates, and the
1751
+ // directory resolver uses those two services through that context.
1752
+ ctx.inject(['slots', MODEL_DIRECTORIES, 'remote', 'remote.session'], (scope) => {
1753
+ const slots = scope.get('slots')
1754
+ const sessions = scope.get('sessions')
1755
+ /**
1756
+ * The locale SERVICE, handed to the component as an observable source.
1757
+ *
1758
+ * Its snapshot is `{ active, locales, revision }` — NOT `{ locale }`.
1759
+ * Reading the wrong field made every UI string fall back to Chinese no
1760
+ * matter which language the shell was set to.
1761
+ *
1762
+ * It is passed through as a `{ getSnapshot, subscribe }` pair rather
1763
+ * than resolved to a string here: `inject` is cached per session
1764
+ * binding, so a string captured now would never follow a language
1765
+ * switch made while the composer is on screen.
1766
+ */
1767
+ const locale = scope.get('locale')
1768
+ const localeSource = locale !== undefined &&
1769
+ typeof locale.getSnapshot === 'function' &&
1770
+ typeof locale.subscribe === 'function'
1771
+ ? locale
1772
+ : undefined
1773
+ const initialLocale = localeSource?.getSnapshot?.()?.active
1774
+ const localeId = typeof initialLocale === 'string' && initialLocale.length > 0
1775
+ ? initialLocale
1776
+ : 'zh-CN'
1777
+
1778
+ if (slots === undefined) {
1779
+ report('槽位未注册:slots 缺失')
1780
+ return
1129
1781
  }
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
1782
+
1783
+ /**
1784
+ * Register the seat.
1785
+ *
1786
+ * Two details matter:
1787
+ *
1788
+ * - NO `children` table. The child slot `conversation.input.model.label`
1789
+ * is declared by the shipped occupant, and a child declaration is
1790
+ * EXCLUSIVE per slot key: `slots.register` throws
1791
+ * `slot "…label" is already declared (by …)` the moment a second entry
1792
+ * offers the same child. The throw escapes the slot callback and the
1793
+ * picker stops rendering. Disposing first does not help either — the
1794
+ * declaration belongs to the shipped entry, which never disposes.
1795
+ *
1796
+ * - The service lookup happens INSIDE the callback. `models` may not be
1797
+ * visible yet when `apply` runs; reading it here means the registration
1798
+ * waits for it instead of binding to undefined.
1799
+ *
1800
+ * The cost of dropping the child declaration is that provider badges
1801
+ * mounted at `conversation.input.model.label` no longer render here. That
1802
+ * is the same trade the reference plugin makes, and it is what makes the
1803
+ * registration survive.
1804
+ */
1805
+ let disposeCurrent = null
1806
+ slots.inject(SLOT, () => {
1807
+ const models = scope.get(MODEL_DIRECTORIES)
1808
+ if (models === undefined) {
1809
+ report('modelDirectories 缺失,暂不注册')
1810
+ return () => {}
1163
1811
  }
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()
1812
+ // The callback can fire more than once (a service re-appearing re-runs
1813
+ // it). Registering twice at the same priority throws
1814
+ // `single slot "…" already has a registration`, so the previous one is
1815
+ // released first. Without a children table this is safe — the child
1816
+ // declaration is what cannot be released.
1817
+ if (disposeCurrent !== null) {
1818
+ try {
1819
+ disposeCurrent()
1820
+ } catch {
1821
+ // Already gone.
1822
+ }
1823
+ disposeCurrent = null
1824
+ }
1825
+ report('槽位回调已运行,准备注册 occupant')
1826
+ disposeCurrent = slots.register({
1827
+ name: SLOT,
1828
+ priority: SHADOW_PRIORITY,
1829
+ inject: (sessionId) => {
1830
+ const directory = models.directoryFor(sessionId)
1831
+ const available = typeof sessions?.subagentAddress === 'function'
1832
+ ? sessions.subagentAddress(sessionId) === undefined
1833
+ : true
1834
+ return {
1835
+ localeId,
1836
+ localeSource,
1837
+ available,
1838
+ // The shipped registration hands over the STORE, not the
1839
+ // controller; the component subscribes to it directly.
1840
+ directory: directory.store,
1841
+ load: () => {
1842
+ if (available) directory.load().catch(() => {})
1843
+ },
1214
1844
  /**
1215
- * The directory's own snapshot, reported once per registration.
1845
+ * Submit one triple.
1216
1846
  *
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.
1847
+ * The RESULT IS PASSED THROUGH UNCHANGED. `ModelDirectory.select`
1848
+ * resolves to `{ ok: true, value }` or `{ ok: false, error }`,
1849
+ * and the component — like the shipped one — reads `ok` to
1850
+ * decide whether to close the panel and which failure to show.
1851
+ * Mapping it to a boolean here made every successful pick look
1852
+ * like a failure: the model switched but the list stayed open,
1853
+ * and a settled effort level dropped its optimistic draft.
1222
1854
  */
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
- }
1855
+ select: (selection) => available
1856
+ ? directory.select(selection)
1857
+ : Promise.resolve(undefined)
1235
1858
  }
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)
1859
+ }
1860
+ }, ModelPicker)
1861
+ return () => {
1862
+ if (disposeCurrent !== null) {
1863
+ try { disposeCurrent() } catch { /* already gone */ }
1864
+ disposeCurrent = null
1865
+ }
1866
+ }
1867
+ })
1868
+ })
1251
1869
  }
1252
1870
  }
1253
1871
  }