dsh-model-flatmap 1.0.2 → 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
@@ -47,16 +47,23 @@ and the agent teamwork switch are all visible at once.
47
47
  `acme-7b` are one `Acme`), and dictionary families are never split by
48
48
  generation. A provider prefix does not hide the brand (`codebuddy-hy3` →
49
49
  `HY3`).
50
- - **Family-grouped rows are provider-primary.** A family heading says what the
51
- models *are*, so the family name is not repeated in a row. The row's primary
52
- text is the provider name — the one thing a heading cannot tell you when a
53
- family spans providers. The full model name and description stay in the row
54
- tooltip. Only when one provider offers several models in the same family do
55
- those rows carry a short secondary variant derived from the id tail (e.g.
56
- `hy4free` → `free`, `hy3-x` → `x`); the plain base model carries no tag.
57
- Colliding variants fall back to the unique model id. Under *By provider* the
58
- heading already is the provider, so the primary text stays the model name and
59
- no secondary tag is drawn.
50
+ - **Family-grouped rows are provider-primary, with a model tail.** A family
51
+ heading says what the models *are*, so the family name is not repeated in a
52
+ row. The row's primary text is the provider name — the one thing a heading
53
+ cannot tell you when a family spans providers. The full model name and
54
+ description stay in the row tooltip. What the heading *and* the provider
55
+ cannot say — which model of the family this row is, and the catalog's rate
56
+ multiplier — is drawn as a quieter tail at the END of the row. The tail is
57
+ the model name with the family heading and the provider's own label removed,
58
+ so `Qwen3.8-Flash-Next` under `Qwen` reads `3.8-Flash-Next`, `Qwen3.8-27B`
59
+ reads `3.8-27B`, `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` reads
60
+ `V4.1-Flash · x0.00`, and `豆包 2.1 Turbo` under provider `豆包(doubao)` reads
61
+ `2.1 Turbo`. Version numbers are KEPT (they are the型号 the heading lacks),
62
+ and a multiplier inside the tail is drawn two sizes smaller and bold, exactly
63
+ as it is inside a model name under *By provider*. Two rows of one provider
64
+ that still read alike fall back to the unique model id; a model whose name
65
+ adds nothing draws no tail. Under *By provider* the heading already is the
66
+ provider, so the primary text stays the model name and no tail is drawn.
60
67
  - **Adaptive columns.** The panel measures one group title and one model row,
61
68
  subtracts the fixed sections from the 320 px cap, and picks the smallest
62
69
  column count from 1 to 3 that fits. If even three columns overflow, it keeps
@@ -97,9 +104,10 @@ and the agent teamwork switch are all visible at once.
97
104
  translucent white chip so it stays legible on the blue fill.
98
105
  - **Out-of-balance models are struck through.** When a model call fails because
99
106
  the account has no usable balance, credit, or quota left, that model's row
100
- text is drawn with a `line-through` decoration, and the strike stays until a
107
+ text is drawn with a `line-through` decoration. The strike stays until a
101
108
  later call for the same model returns something other than an
102
- insufficient-balance failure. The row is not recoloured, so the strike reads
109
+ insufficient-balance failure, **or** until it has gone 24 hours without a new
110
+ attempt — whichever comes first. The row is not recoloured, so the strike reads
103
111
  the same on the selected row's blue band and in both themes, and it does not
104
112
  compete with the error strip's red. Only the row's text (the name and a
105
113
  family variant tag) is struck; the badge chip and the pending spinner are
@@ -137,6 +145,21 @@ and the agent teamwork switch are all visible at once.
137
145
  that failed for a different reason. A cancellation (`aborted`), a `blocked`
138
146
  turn, and the synthetic `interrupted` / `forked` closers clear nothing —
139
147
  the model never answered, so they say nothing about the account.
148
+ - **How long it is drawn without one.** Every record keeps the wall-clock
149
+ time of the observation that made it, which is what separates "this model is
150
+ out of balance" from "this model has not been tried since that verdict". A
151
+ mark older than 24 hours is not drawn: an exhausted balance gets topped up
152
+ and a quota resets daily, so a strike nobody refreshed has stopped being
153
+ information. A retry restamps the record and restarts the window, so the
154
+ rule is "24 hours since the newest thing we learned about this route", not
155
+ "24 hours since the row was painted". Retiring a strike is the one transition
156
+ no event reports, so the store watches the clock itself: on every fold, when
157
+ a seat subscribes, when the panel is opened, and on a timer set for the
158
+ soonest deadline (that handle is `unref`'d, so a day-long wait never holds
159
+ the host process). The record SURVIVES its own expiry, as the watermark for
160
+ the route — which is why re-folding the very failure that made it, after a
161
+ reload or when a second Session's older history is walked, cannot put the
162
+ decoration back.
140
163
  - **The marks are global, not per conversation.** Whether a model has quota
141
164
  left is a fact about the model route and its account, so the state is NOT
142
165
  scoped to the Session a panel happens to render for. One module-level store
@@ -147,8 +170,12 @@ and the agent teamwork switch are all visible at once.
147
170
  - **It survives a reload.** The marks are persisted to `localStorage`
148
171
  (origin-scoped, like the grouping choice), so a fresh page load starts from
149
172
  the last known state instead of an empty one. Reads and writes degrade
150
- silently when storage is unavailable, and records older than 7 days are
151
- dropped so a stale strike cannot live forever.
173
+ silently when storage is unavailable. Two lifetimes apply to that record and
174
+ they are not the same one: a mark stops being DRAWN after 24 hours without a
175
+ new observation, while the record itself is kept for 7 days, because it is
176
+ also the watermark that keeps an older replayed failure from restamping the
177
+ route. So a reload of a day-old verdict restores the row as plain text, and
178
+ the fold that re-reads the failure that produced it changes nothing.
152
179
  - **The newest observation wins.** A Session feed carries no cross-Session
153
180
  sequence number, but every durable event keeps the Host's wall-clock `time`.
154
181
  Each route keeps the time of its last decision, and an observation older
@@ -168,6 +195,126 @@ and the agent teamwork switch are all visible at once.
168
195
  the shared footer line described under *Reasoning levels*, immediately right
169
196
  of the reasoning group when both fit on one line.
170
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
+
171
318
  ## Data and interaction
172
319
 
173
320
  Everything goes through public Client APIs:
@@ -182,10 +329,20 @@ Everything goes through public Client APIs:
182
329
  Session the Session list names (`ctx.sessions.list`) to derive the global
183
330
  per-model insufficient-balance marks, persists them to `localStorage`, and
184
331
  every seat subscribes through `useSyncExternalStore` so a mark transition
185
- repaints the panel while every other event stays quiet.
332
+ repaints the panel while every other event stays quiet. The same store also
333
+ re-reads the clock on subscribe, on panel open and on a self-armed timer,
334
+ because a strike reaching its 24-hour age is a transition no feed will ever
335
+ report. A snapshot is replaced only when the set of drawn marks actually
336
+ changed, so those extra reads cannot churn the panel.
186
337
  - `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
187
338
  - `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
188
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.
189
346
 
190
347
  No host component, stylesheet, or DOM is read or restyled.
191
348
 
@@ -231,13 +388,38 @@ The band also carries a low-key frame: a hairline border
231
388
  group start clearly without boxing the group's rows — a group's rows may flow
232
389
  across columns, so only its label is framed (see DEC-003).
233
390
 
391
+ ### Long text: one-pass hover marquee
392
+
393
+ A model name, or a group heading, that does not fit its column is ellipsised.
394
+ Resting the pointer on it for 0.5 s starts a single, eased, leftward pass that
395
+ stops when the last glyph reaches the window's right edge; leaving resets it
396
+ immediately with no animation. The pass runs ONCE, is `ease-in-out` rather than
397
+ `linear`, and honours `prefers-reduced-motion: reduce` by not running at all.
398
+
399
+ Both a model row and a group title use the same structure, so only the text
400
+ slides and the frame never does: a clipped **window** (`overflow: hidden` +
401
+ `text-overflow: ellipsis`) wraps a **track** (`width: max-content`) that is
402
+ allowed to be wider than the window. For a row the frame is the row itself, so
403
+ the right-aligned tail, badge (`限时免费`, `x0.1`) and spinner never move; for a
404
+ title the frame is the band, so its fill, frame, radius and elevation stay put.
405
+ One state machine drives both — keyed by the row's route or the title's block
406
+ key — so only one thing in the panel scrolls at a time. The travel distance is
407
+ the measured overflow, and the duration is that distance divided by 50 px/s
408
+ (see DEC-021, DEC-022, DEC-023).
409
+
234
410
  ## Limitations
235
411
 
236
412
  - The panel replaces the composer seat only; the `/model` popup is untouched.
237
- - The panel does not host the `conversation.input.model.label` and
238
- `conversation.input.model.options` child slots. The shipped registration keeps
239
- those declarations alive, so other plugins' contributions stay valid; their
240
- 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.
241
423
  - Addressed subagent sessions hide the seat, matching the shipped selector.
242
424
  - The badge comes from the catalog entry, so a provider that publishes no badge
243
425
  shows none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-flatmap",
3
- "version": "1.0.2",
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": {