dsh-model-flatmap 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DETAIL.md CHANGED
@@ -195,6 +195,126 @@ and the agent teamwork switch are all visible at once.
195
195
  the shared footer line described under *Reasoning levels*, immediately right
196
196
  of the reasoning group when both fit on one line.
197
197
 
198
+ ## The trigger
199
+
200
+ The trigger is the same chip the shipped picker draws (13/20 regular, 28 px,
201
+ rounded-sm), and it shows the same *content* — which is more than a model name.
202
+
203
+ **The name is a slot, not a string.** The shipped `ModelSelect` does not paint
204
+ `model.name` on its trigger: it renders `conversation.input.model.label` with
205
+ `placement: 'trigger'` and lets the winning contributor decorate the name. That
206
+ is where a connection plugin puts things the catalog does not carry: a promotion
207
+ chip (`夜间折扣`), a rate, a "new on this machine" marker, and — in the case of
208
+ `dsh-connect-workbuddy` — a load mark: one, two or three queued people, or a
209
+ rocket when nothing is waiting. A bare name therefore shows strictly less than
210
+ the native trigger, which is what this plugin used to do.
211
+
212
+ **This plugin renders that slot's winner.** It cannot *declare* the child slot
213
+ (the shipped entry already did, and a second declaration throws
214
+ `already declared`) — but declaring is not needed to read it. The declaration
215
+ outlives the shadowing entry, so `slots.entriesOfSlot('conversation.input.model.label')`
216
+ still names the winning entry, and its `component` can be rendered with the
217
+ seats the renderer would have supplied:
218
+
219
+ - `t` — bound from the entry's own `locale` namespace through `locale.bind(ns)`;
220
+ - the business face — `entry.inject(sessionId)`, the renderer's own contract for
221
+ a session-scoped entry;
222
+ - the owner props — `{ provider, model, placement: 'trigger', selected: true }`,
223
+ exactly the share the shipped picker passes.
224
+
225
+ **The occupant is third-party code, so it is contained.** It runs without the
226
+ renderer's per-entry error boundary, so a separate `LabelBoundary` wraps it. A
227
+ crash costs the decoration and never the seat: the boundary answers `undefined`
228
+ and the trigger falls through to the catalog name. The failure is logged once.
229
+
230
+ **No occupant still gets the catalog badge.** When the cell is empty — or the
231
+ occupant is absent, crashed, or on a Host without `entriesOfSlot` — the trigger
232
+ draws the shipped fallback: the model's own `model.badge` as a chip before the
233
+ name. This is reproduced rather than imported (the host's `Tag` lives in a
234
+ primitives package this plugin does not depend on), and it matters even with an
235
+ occupant installed: an occupant that does not reproduce a sibling's `model.badge`
236
+ would otherwise lose the promotion marker.
237
+
238
+ **Registrations that land late still appear.** The trigger subscribes to the
239
+ slot through `slots.subscribe(key, fn)` / `slots.getVersion(key)`, so a
240
+ connection plugin that registers a moment after boot gets its chip on the
241
+ trigger without a page reload. A Host without those accessors keeps the first
242
+ read and never notifies.
243
+
244
+ Because native mode (below) hands the seat to the Host, which renders the same
245
+ slot, the trigger is now *one* implementation in both modes — the consistency
246
+ this feature exists for.
247
+
248
+ ## Native mode
249
+
250
+ The footer's trailing `默认样式` / `Default style` button hands the composer's model seat
251
+ back to whatever the Host ships in `conversation.input.model`, and back again.
252
+
253
+ **How the switch works.** The plugin does NOT render the host's control — it
254
+ stops shadowing it. This entry registers at a negative priority, so it wins the
255
+ `single` slot; a `single` slot keeps every registration and renders only the
256
+ highest-priority live one. Native mode therefore `dispose()`s this plugin's
257
+ entry, which elects the shipped one underneath: the host mounts its own
258
+ `ModelSelect`, whatever that is in the installed version. Nothing is imported,
259
+ copied, or re-mounted, so an upgraded DSH shows its own newest picker and this
260
+ mode structurally cannot drift out of date.
261
+
262
+ The alternative — pulling the shipped component into our own tree — is not
263
+ available: the `model-selection` package exports no component, and every
264
+ `renderSlot` child (`conversation.input.model.label` / `.options`) is bound to
265
+ the entry that declares it, so another entry calling it throws
266
+ `StaleAuthorizationError` / `SlotOwnershipError`. See DEC-027.
267
+
268
+ **Stepping aside is verified, never assumed.** Before disposing, the plugin asks
269
+ `slots.entries('conversation.input.model')` for the registrations other than its
270
+ own. If there is none — the shipped package disabled, uninstalled, or a Host too
271
+ old to enumerate entries — the panel stays mounted and the button reads disabled
272
+ with a tooltip explaining why. A persisted `native` mode from a previous install
273
+ therefore lands on the flat panel instead of an empty composer.
274
+
275
+ **The way back.** Native mode disposes the entry that owns the footer button, so
276
+ the button cannot switch back. The return trip instead rides the HOST picker's
277
+ own panel: `conversation.input.model.options` is the list child slot the shipped
278
+ `ModelSelect` declares and renders directly after its Model / Effort rows, so our
279
+ control appears at the bottom of the native menu itself. It belongs to the
280
+ shipped entry, not to ours, so it outlives the disposal; and it renders `null` in
281
+ flat mode, so the shipped menu looks exactly as shipped while the panel is on
282
+ screen. Clicking it dismisses the host menu first (the entry that owns the menu
283
+ is about to be disposed) and then flips the mode back.
284
+
285
+ **It does not look like a menu row.** Every row above it answers "which value?";
286
+ this one performs an action. So it is drawn as a separated bottom band — a 1px
287
+ `--dsw-alias-border-l2` rule above, then a full-width 30 px outlined button
288
+ (`--dsw-alias-border-l3`, `--dsw-radius-sm`, 12/18 medium label, 14 px icon) with
289
+ a hover fill and a focus ring. The slot gives occupants only a `display: contents`
290
+ anchor, so that whole box is ours; nothing about the host menu's own rows has to
291
+ be imitated. The distinction is pinned by a test that asserts the button carries
292
+ no `role="menuitem"`.
293
+
294
+ Nothing here is a copy of the host control: the row is one entry in a slot the
295
+ installed package publicly declares. The former placement — a chip in
296
+ `conversation.input.right`, beside the native
297
+ trigger — was dropped because it read as a toolbar control belonging to nothing
298
+ in particular, and because it forced the mode control to sit outside the panel
299
+ it acts on.
300
+
301
+ **One shared store.** Both controls read one page-wide mode store, so a flip made
302
+ from either is what the other sees. The store persists the choice to
303
+ `localStorage` (`dshmf:mode`, the same origin-scoped mechanism as the grouping
304
+ choice) and degrades to `flat` whenever storage is unavailable — a missing store
305
+ must never leave the composer without a model control.
306
+
307
+ **Reported state is the visible state.** The panel only renders while it owns the
308
+ cell, so the stored mode and the on-screen mode can disagree in exactly one case:
309
+ `native` persisted with nothing to inherit the cell. The button's `aria-pressed`
310
+ then reports flat, because it describes what is on screen, not what is in
311
+ storage.
312
+
313
+ **What changes across a switch.** Coming back from native mode remounts the
314
+ panel, so it opens closed with an empty query. Focus follows the disposed
315
+ button down to the document; cross-plugin DOM queries for focus are not allowed,
316
+ so it is not chased.
317
+
198
318
  ## Data and interaction
199
319
 
200
320
  Everything goes through public Client APIs:
@@ -217,6 +337,12 @@ Everything goes through public Client APIs:
217
337
  - `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
218
338
  - `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
219
339
  host's own `agent-team` wording when that namespace is registered.
340
+ - `slots.register()` / its disposer and `slots.entries()` / `slots.subscribe()`
341
+ drive native mode: disposing this plugin's `conversation.input.model` entry
342
+ elects whatever the Host registered underneath, and `entries()` is what makes
343
+ that verifiable before the switch. `slots.subscribe()` re-runs the reconcile,
344
+ so a shipped picker that appears (or disappears) later enables or disables the
345
+ button without a reload.
220
346
 
221
347
  No host component, stylesheet, or DOM is read or restyled.
222
348
 
@@ -284,10 +410,16 @@ the measured overflow, and the duration is that distance divided by 50 px/s
284
410
  ## Limitations
285
411
 
286
412
  - The panel replaces the composer seat only; the `/model` popup is untouched.
287
- - The panel does not host the `conversation.input.model.label` and
288
- `conversation.input.model.options` child slots. The shipped registration keeps
289
- those declarations alive, so other plugins' contributions stay valid; their
290
- content simply is not drawn inside this panel.
413
+ - The panel's GRID does not host the `conversation.input.model.label` child
414
+ slot: rows draw the catalog name plus `model.badge`. Only the TRIGGER renders
415
+ that slot's winner, because that is what the native trigger does. The
416
+ `conversation.input.model.options` slot is not drawn anywhere in the panel.
417
+ The shipped registration keeps both declarations alive, so other plugins'
418
+ contributions stay valid either way.
419
+ - The trigger's label cell renders ANOTHER PLUGIN'S component. It is written
420
+ against the documented `ModelLabelOwner` props, but nothing guarantees every
421
+ future contributor stays compatible; `LabelBoundary` degrades a crash to the
422
+ catalog name, which is the same fallback the host uses for an empty cell.
291
423
  - Addressed subagent sessions hide the seat, matching the shipped selector.
292
424
  - The badge comes from the catalog entry, so a provider that publishes no badge
293
425
  shows none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-flatmap",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Flat, two-level-free model picker for the DSH composer: every model and reasoning level on one surface.",
5
5
  "type": "module",
6
6
  "exports": {