dsh-model-flatmap 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2378 @@
1
+ /**
2
+ * Flat model picker — browser half.
3
+ *
4
+ * Replaces the composer's `conversation.input.model` seat with ONE flat panel:
5
+ * search, a provider/family grouping switch, the whole model grid, the current
6
+ * model's reasoning levels, and the agent-teamwork switch — no second-level
7
+ * menus anywhere.
8
+ *
9
+ * Everything is read and written through public Client APIs only:
10
+ * - `ctx.modelDirectories.directoryFor(sessionId)` → the per-Session directory
11
+ * (`store` for data + change subscription, `load()`, `select()`);
12
+ * - `ctx.get('remote').agentTeams.setEnabled()` → the teamwork selection;
13
+ * - `ctx.locale` → copy, catalog badge text resolution, and the host's own
14
+ * wording for the teamwork row;
15
+ * - the slot's standard props (`sessionId`, `useProjection`, `useSession`,
16
+ * `useSessions`) → Session state.
17
+ *
18
+ * Registration uses a negative priority so this entry shadows the shipped
19
+ * `ModelSelect` while it is active; disabling the plugin restores the native
20
+ * control with no residue. The shipped entry keeps its own child-slot
21
+ * declarations (`conversation.input.model.label` / `.options`) alive, so no
22
+ * other plugin's registration is invalidated by this replacement; this panel
23
+ * simply does not host them, and draws the catalog's own model badge instead.
24
+ * No host component, stylesheet or DOM is read.
25
+ */
26
+ window.__ModuleLoader__.load({
27
+ id: 'dsh-model-flatmap',
28
+ factory(require) {
29
+ const React = require('react')
30
+ const ReactDOM = require('react-dom')
31
+ const h = React.createElement
32
+ const { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } = React
33
+ const useSyncExternalStore = React.useSyncExternalStore
34
+
35
+ // ---------------------------------------------------------------------
36
+ // Stylesheet — theme tokens only. Inserted once per module generation with
37
+ // the same data-plugin tags the host uses, so disposal is owned upstream.
38
+ // ---------------------------------------------------------------------
39
+ const CSS = `
40
+ .dshmf-root { position: relative; min-width: 0; }
41
+
42
+ /* Trigger: the shipped composer seat's chrome (13/20 regular, 28px chip,
43
+ rounded-sm), so the control reads as the button it replaces. */
44
+ .dshmf-trigger {
45
+ display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 360px;
46
+ height: 28px; padding: 0 4px 0 8px; border: none; border-radius: var(--dsw-radius-sm);
47
+ outline: none; background: transparent; color: var(--dsw-alias-label-secondary);
48
+ font-size: 13px; line-height: 20px; font-weight: 400; cursor: pointer;
49
+ }
50
+ @media (hover: hover) { .dshmf-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } }
51
+ .dshmf-trigger:focus-visible:not([data-selection-focus]) {
52
+ box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
53
+ }
54
+ .dshmf-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
55
+ .dshmf-triggerIcon { display: var(--dsh-composer-model-icon-display, none); flex: 0 0 auto; }
56
+ .dshmf-triggerText { display: var(--dsh-composer-model-text-display, block); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ .dshmf-triggerEffort { flex-shrink: 1000; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-caption); }
58
+ .dshmf-chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; }
59
+ .dshmf-chevronOpen { transform: rotate(180deg); }
60
+
61
+ /* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
62
+ corners), capped at 320px; only the grid scrolls. */
63
+ .dshmf-panel {
64
+ box-sizing: border-box; position: fixed; z-index: 1100;
65
+ display: flex; flex-direction: column; gap: 4px; padding: 6px;
66
+ max-height: 320px; overflow: hidden;
67
+ border: 0; border-radius: var(--dsw-radius-lg);
68
+ background: var(--dsw-menu-surface-fill);
69
+ -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
70
+ backdrop-filter: var(--dsw-menu-backdrop-filter, none);
71
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
72
+ box-shadow: var(--dsw-elevation-prominent);
73
+ color: var(--dsw-alias-label-primary);
74
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
75
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
76
+ }
77
+
78
+ /* Fixed sections. */
79
+ .dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
80
+ .dshmf-search { flex: 1 1 70%; display: flex; align-items: center; gap: 4px; min-width: 0; height: 24px; padding: 0 6px; border-radius: var(--dsw-radius-sm); }
81
+ .dshmf-search:focus-within { background: var(--dsw-alias-interactive-bg-hover); }
82
+ .dshmf-input {
83
+ flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0;
84
+ background: transparent; color: var(--dsw-alias-label-primary);
85
+ font-family: inherit; font-size: 12px; line-height: 16px;
86
+ }
87
+ .dshmf-input::placeholder { color: var(--dsw-alias-label-caption); }
88
+ @media (pointer: coarse) { .dshmf-input { font-size: 16px; } }
89
+ .dshmf-clear {
90
+ flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
91
+ width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; corner-shape: round;
92
+ background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer;
93
+ }
94
+ .dshmf-clear:hover { background: var(--dsw-alias-interactive-bg-hover); }
95
+ .dshmf-clear:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -1px; }
96
+
97
+ .dshmf-spin {
98
+ width: 12px; height: 12px; flex: 0 0 auto; border-radius: 50%; corner-shape: round;
99
+ border: 1.5px solid var(--dsw-alias-border-l3); border-top-color: var(--dsw-alias-label-tertiary);
100
+ animation: dshmf-spin 700ms linear infinite;
101
+ }
102
+ @keyframes dshmf-spin { to { transform: rotate(360deg); } }
103
+
104
+ .dshmf-seg { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover); }
105
+ .dshmf-segBtn {
106
+ height: 22px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-xs);
107
+ background: transparent; color: var(--dsw-alias-label-secondary);
108
+ font-family: inherit; font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer;
109
+ transition: color 120ms ease, background 120ms ease;
110
+ }
111
+ .dshmf-segBtn[aria-pressed="true"] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-elevation-soft); }
112
+ @media (hover: hover) { .dshmf-segBtn:hover:not([aria-pressed="true"]) { color: var(--dsw-alias-label-primary); } }
113
+ .dshmf-segBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
114
+
115
+ .dshmf-error {
116
+ flex: 0 0 auto; display: flex; align-items: flex-start; gap: 6px; padding: 4px 7px;
117
+ border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover-danger);
118
+ color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
119
+ }
120
+
121
+ /* The only scrolling region. */
122
+ .dshmf-grid { flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto; display: grid; align-items: start; }
123
+ .dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
124
+ /* When more than one column is laid out, separate adjacent columns with a
125
+ faint vertical hairline so the eye can tell the dense model flows apart.
126
+ The line uses the lightest border token to stay subtle, and the divided
127
+ column keeps a little right padding so model text never hugs the line.
128
+ Only columns that actually sit before another one get it (see renderColumn),
129
+ so a single-column layout stays borderless. */
130
+ .dshmf-col--divided { border-right: 1px solid var(--dsw-alias-border-l3); padding-right: 10px; }
131
+ /* Provider / family label: a filled, rounded band that reads as a section
132
+ header, clearly separated from the model rows below it. Bare host
133
+ group-header fill is only a few levels from the translucent panel surface.
134
+ The band floats on an opaque grey overlay surface with soft elevation — the
135
+ same recipe the active segment uses. It deliberately steps AWAY from the
136
+ transparent panel surface in both themes: in light it resolves to a soft
137
+ grey (#e9ecf2) that is visibly darker than the white panel, and in dark it
138
+ resolves to a light grey (#61666b) that is visibly lighter than the
139
+ near-black surface — so the band reads as a distinct section header with
140
+ no hard-coded colour and no second theme branch (see DEC-005). Smaller but
141
+ bold type (and the primary label colour) sets it apart from the 13px
142
+ regular row text without borrowing from the row's interactive states.
143
+ A hairline frame plus a short grey (border-level-3) left edge marks each
144
+ group start: visible, never loud, and theme-neutral — no colour accent. The rows themselves
145
+ stay borderless, so the dense flow across columns is kept (see DEC-003,
146
+ DEC-005). */
147
+ .dshmf-groupTitle {
148
+ align-self: flex-start; max-width: 100%;
149
+ box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
150
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
151
+ border: 1px solid var(--dsw-alias-border-l2);
152
+ border-left: 3px solid var(--dsw-alias-border-l3);
153
+ border-radius: var(--dsw-radius-xs);
154
+ background: var(--dsw-alias-bg-overlay);
155
+ box-shadow: var(--dsw-elevation-soft);
156
+ color: var(--dsw-alias-label-primary);
157
+ font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
158
+ }
159
+ .dshmf-row {
160
+ box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
161
+ height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
162
+ background: transparent; color: var(--dsw-alias-label-primary);
163
+ font-family: inherit; font-size: 13px; line-height: 18px; font-weight: 400; text-align: left;
164
+ cursor: pointer; min-width: 0;
165
+ }
166
+ @media (hover: hover) { .dshmf-row:hover:not(:disabled):not([aria-pressed="true"]) { background: var(--dsw-alias-interactive-bg-hover); } }
167
+ .dshmf-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
168
+ .dshmf-row:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
169
+ /* Selected model: a solid deep-blue band with white bold text — the row
170
+ itself says "this one is active", so no checkmark and no reserved tail
171
+ column is needed (that 14px sat empty on every unselected row).
172
+ The pairing is pinned to the static deepseek-600 + white rather than the
173
+ alias --dsw-alias-state-business-primary, because that alias flips to a
174
+ LIGHT blue with dark text in the dark theme; the request is a deep blue
175
+ with white text in both themes, and these two static tokens are the same
176
+ in light and dark. */
177
+ .dshmf-row[aria-pressed="true"],
178
+ .dshmf-row[aria-pressed="true"]:focus-visible {
179
+ background: var(--dsw-static-deepseek-600);
180
+ color: var(--dsw-static-neutral-bluish-00);
181
+ font-weight: 600;
182
+ }
183
+ .dshmf-row[aria-pressed="true"] .dshmf-rowProvider { color: inherit; opacity: .82; }
184
+ .dshmf-row[aria-pressed="true"] .dshmf-badge {
185
+ background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
186
+ color: inherit;
187
+ }
188
+ .dshmf-rowName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
189
+ /* The model's last call failed on insufficient balance / credit / quota, and no
190
+ later call for the same model has cleared it. The row text is struck through
191
+ rather than recoloured: the strike survives both themes and the selected
192
+ row's blue band, and it does not compete with the error strip's own red.
193
+ The decoration is applied to the row's text only (name + variant tag), not
194
+ to the badge chip or the spinner. */
195
+ .dshmf-row[data-insufficient="true"] .dshmf-rowName,
196
+ .dshmf-row[data-insufficient="true"] .dshmf-rowProvider {
197
+ text-decoration: line-through;
198
+ text-decoration-thickness: 1px;
199
+ }
200
+ /* A multiplier marker lifted out of the model name (rate/size tags like
201
+ "x0.01", "x1.5", "X 2.2"). The characters stay in place and the full name is
202
+ unchanged; only this run is drawn two sizes smaller and bold, so the
203
+ multiplier reads as an aside without disturbing the row's text rhythm
204
+ (see renderRow's nameRuns). */
205
+ .dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
206
+ /* Secondary text in a family-grouped row. The primary text is the provider,
207
+ so this is only a short variant tail shown when one provider offers several
208
+ models in the same family. Quieter than the primary text (caption colour,
209
+ 11px) and shrinks before it does. No width cap: a cap truncates a perfectly
210
+ readable label ("CodeBuddy …") in a column that had room for it. */
211
+ .dshmf-rowProvider {
212
+ flex: 0 1 auto; min-width: 0;
213
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
214
+ color: var(--dsw-alias-label-caption);
215
+ font-size: 11px; line-height: 16px; font-weight: 400;
216
+ }
217
+ .dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
218
+
219
+ /* Catalog badge (for example a third-party rate multiplier). Rendered only
220
+ when the catalog publishes text, so a badge-less model keeps no gap. */
221
+ .dshmf-badge {
222
+ flex: none; display: inline-flex; align-items: center; padding: 0 6px;
223
+ border-radius: 999px; corner-shape: round;
224
+ font-size: 10px; line-height: 16px; font-weight: 500; white-space: nowrap;
225
+ background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary);
226
+ }
227
+ .dshmf-badge[data-tone="info"] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); color: var(--dsw-alias-state-business-primary); }
228
+ .dshmf-badge[data-tone="success"] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
229
+ .dshmf-badge[data-tone="warning"] { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); color: var(--dsw-alias-state-warn-primary); }
230
+
231
+ .dshmf-empty { grid-column: 1 / -1; padding: 10px 8px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
232
+ .dshmf-failure { grid-column: 1 / -1; padding: 2px 6px; color: var(--dsw-alias-state-warn-label); font-size: 11px; line-height: 16px; overflow-wrap: anywhere; }
233
+
234
+ /* Adaptive footer: the reasoning group and the teamwork group share one line
235
+ when BOTH fit side by side with a guaranteed 30px gap, and otherwise fall
236
+ back to one row each (the original two-line look). The decision is pure CSS:
237
+ the footer stretches to the panel width, each child keeps its natural
238
+ max-content width (flex 0 0 auto), and flex-wrap plus a 30px column-gap
239
+ puts them on one line exactly when reason + 30 + team <= panel width.
240
+ No JS width guessing, so it stays correct across panel resize, theme and
241
+ font changes. A max-width of 100% lets a child that ends up alone on a line
242
+ cap to the panel width and wrap internally instead of overflowing.
243
+ The teamwork row carries no divider of its own (DEC-011 keeps the single
244
+ grey rule above the reasoning group only), so a shared line needs no
245
+ border juggling. */
246
+ .dshmf-footer {
247
+ flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
248
+ column-gap: 30px; row-gap: 4px;
249
+ }
250
+
251
+ /* Reasoning levels: a caption and a multi-segmentation control on one line;
252
+ the track wraps below the caption only when the row cannot fit. The
253
+ segment visual mirrors the header's grouping control, so the panel has
254
+ one visual language for "one of many, exactly one active". */
255
+ /* One obvious grey divider between the scrolling model grid and the fixed
256
+ reasoning row. Host border tokens are faint by design, so this line uses
257
+ explicit greys: medium grey on the light panel, a lighter near-white grey
258
+ on the dark panel. The teamwork row below has no line (DEC-011). */
259
+ .dshmf-reason {
260
+ flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
261
+ gap: 2px 6px; max-width: 100%;
262
+ border-top: 1px solid #9CA3AF;
263
+ padding-top: 6px;
264
+ }
265
+ body[data-ds-dark-theme] .dshmf-reason,
266
+ html[data-ds-dark-theme] .dshmf-reason { border-top-color: #6B7280; }
267
+ .dshmf-reasonLabel {
268
+ flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
269
+ white-space: nowrap;
270
+ color: var(--dsw-alias-label-tertiary);
271
+ font-size: 11px; line-height: 16px; font-weight: 500;
272
+ }
273
+ .dshmf-reasonLabel svg { flex: 0 0 auto; }
274
+ .dshmf-pills {
275
+ flex: 0 0 auto; max-width: 100%;
276
+ display: inline-flex; align-items: center; gap: 2px;
277
+ padding: 2px;
278
+ border-radius: var(--dsw-radius-sm);
279
+ background: var(--dsw-alias-interactive-bg-hover);
280
+ overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
281
+ }
282
+ .dshmf-pills::-webkit-scrollbar { height: 0; }
283
+ .dshmf-pill {
284
+ flex: 0 0 auto; height: 22px; padding: 0 8px; border: 0;
285
+ border-radius: var(--dsw-radius-xs);
286
+ background: transparent; color: var(--dsw-alias-label-secondary);
287
+ font-family: inherit; font-size: 11px; line-height: 16px; font-weight: 400; white-space: nowrap; cursor: pointer;
288
+ transition: color 120ms ease, background 120ms ease;
289
+ }
290
+ @media (hover: hover) { .dshmf-pill:hover:not(:disabled):not([aria-checked="true"]) { color: var(--dsw-alias-label-primary); } }
291
+ .dshmf-pill[aria-checked="true"] {
292
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
293
+ box-shadow: var(--dsw-elevation-soft); font-weight: 500;
294
+ }
295
+ .dshmf-pill:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
296
+ .dshmf-pill:disabled[aria-checked="true"] { opacity: .5; }
297
+ .dshmf-pill:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
298
+
299
+ /* Teamwork row: label, info button and switch all left-aligned on one line,
300
+ so the switch sits immediately to the right of the title. */
301
+ .dshmf-team {
302
+ flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-start; gap: 8px;
303
+ max-width: 100%;
304
+ min-height: 30px; padding: 0 6px;
305
+ color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px;
306
+ }
307
+ .dshmf-teamLabel { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
308
+ .dshmf-teamName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
309
+ .dshmf-info {
310
+ flex: none; display: inline-flex; align-items: center; justify-content: center;
311
+ width: 16px; height: 16px; color: var(--dsw-alias-label-caption);
312
+ }
313
+ .dshmf-switch {
314
+ box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px;
315
+ border: 0; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer;
316
+ }
317
+ .dshmf-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
318
+ .dshmf-switch:disabled { cursor: default; opacity: .5; }
319
+ .dshmf-switch:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
320
+ .dshmf-thumb { display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-label-primary-foreground); transition: transform .12s ease; }
321
+ .dshmf-switch[aria-checked="false"] .dshmf-thumb { background: var(--dsw-alias-switch-thumb); }
322
+ .dshmf-switch[aria-checked="true"] .dshmf-thumb { transform: translate(16px); }
323
+
324
+ /* Inline disable confirmation, with the host's confirmation copy. */
325
+ .dshmf-confirm { flex: 0 0 auto; display: flex; flex-direction: column; gap: 3px; padding: 6px 8px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-module-platform); }
326
+ .dshmf-confirmTitle { color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; font-weight: 500; }
327
+ .dshmf-confirmBody { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
328
+ .dshmf-confirmActions { display: flex; justify-content: flex-end; gap: 6px; }
329
+ .dshmf-confirmBtn { height: 22px; padding: 0 9px; border: 0; border-radius: var(--dsw-radius-xs); background: transparent; color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
330
+ .dshmf-confirmBtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
331
+ .dshmf-confirmBtn[data-primary="true"] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
332
+ .dshmf-confirmBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
333
+
334
+ /* Off-screen probe: measures one group title and one model row at the real
335
+ font scale, so column fit uses this browser's metrics, not a guess. */
336
+ .dshmf-probe { position: absolute; top: 0; left: 0; width: 160px; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; }
337
+
338
+ @media (prefers-reduced-motion: reduce) {
339
+ .dshmf-spin { animation-duration: 2s; }
340
+ .dshmf-thumb, .dshmf-chevron, .dshmf-pill, .dshmf-segBtn { transition: none; }
341
+ }
342
+ `
343
+ const STYLE_TAG = 'dsh-model-flatmap/panel.css'
344
+ const STYLE_OWNER = 'dsh-model-flatmap'
345
+ if (typeof document !== 'undefined'
346
+ && document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG)}]`) === null) {
347
+ const tag = document.createElement('style')
348
+ tag.dataset.plugin = STYLE_OWNER
349
+ tag.dataset.pluginCss = STYLE_TAG
350
+ tag.textContent = CSS
351
+ document.head.append(tag)
352
+ }
353
+
354
+ // ---------------------------------------------------------------------
355
+ // Constants and helpers
356
+ // ---------------------------------------------------------------------
357
+ const PANEL_MAX = 320
358
+ const PANEL_PAD = 6
359
+ const PANEL_GAP = 4
360
+ const COL_GAP = 10
361
+ const ROW_GAP = 2
362
+ const BALANCE_THRESHOLD = 24
363
+
364
+ const PATH_CHEVRON = 'M3.2 5.6 7 9.4 10.8 5.6'
365
+ const PATH_CLOSE = 'M4 4 10 10M10 4 4 10'
366
+ const PATH_INFO = 'M7 6.4v4.1M7 3.7v.8'
367
+ const PATH_SPARK = 'M7 1.6 8.3 5.7 12.4 7 8.3 8.3 7 12.4 5.7 8.3 1.6 7 5.7 5.7 7 1.6Z'
368
+ const PATH_MODELS = 'M7 2.1 11.9 4.7 7 7.3 2.1 4.7 7 2.1Zm-4.9 5.1L7 9.8l4.9-2.6M2.1 10.3 7 12.9l4.9-2.6'
369
+
370
+ function svg(path, size, extra) {
371
+ return h('svg', {
372
+ width: size, height: size, viewBox: '0 0 14 14', fill: 'none',
373
+ stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
374
+ 'aria-hidden': 'true', focusable: 'false', ...extra,
375
+ }, h('path', { d: path }))
376
+ }
377
+
378
+ /** Resolve provider-authored catalog text without the locale service. */
379
+ function fallbackText(value) {
380
+ return typeof value === 'string' ? value : (value?.en ?? '')
381
+ }
382
+
383
+ function fill(t, key, values) {
384
+ const template = String(t(key))
385
+ if (values === undefined) return template
386
+ return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ''))
387
+ }
388
+
389
+ /**
390
+ * Split a raw query into lowercase search tokens. Whitespace separates
391
+ * tokens, so `mod inte` asks for `mod` AND `inte` rather than for that
392
+ * literal string — which is what lets it find `ModelScope International`,
393
+ * a name the two words never appear in contiguously.
394
+ * @param {string} query - raw search text.
395
+ * @returns {string[]} lowercase, non-empty tokens.
396
+ */
397
+ function searchTokens(query) {
398
+ const raw = typeof query === 'string' ? query : ''
399
+ return raw.trim().toLowerCase().split(/\s+/).filter(token => token !== '')
400
+ }
401
+
402
+ /**
403
+ * Rank ONE token against one name: prefix beats substring beats ordered
404
+ * subsequence. This is the original single-term ranking, unchanged.
405
+ * @param {string} name - lowercase haystack.
406
+ * @param {string} token - lowercase needle.
407
+ * @returns {0|1|2|null} the rank, or null when the token is absent.
408
+ */
409
+ function tokenScore(name, token) {
410
+ if (name.startsWith(token)) return 0
411
+ if (name.includes(token)) return 1
412
+ let at = 0
413
+ for (const ch of token) {
414
+ at = name.indexOf(ch, at)
415
+ if (at === -1) return null
416
+ at += 1
417
+ }
418
+ return 2
419
+ }
420
+
421
+ /**
422
+ * Case-insensitive match: prefix beats substring beats ordered subsequence.
423
+ *
424
+ * A query may hold several whitespace-separated tokens. Every token then
425
+ * has to land somewhere in the name, and the result takes the weakest of
426
+ * them — so `mod inte` finds `ModelScope International`, while `mod zzz`
427
+ * still finds nothing. A single token keeps the original ranking exactly,
428
+ * so existing ordering does not shift.
429
+ */
430
+ function matchScore(name, query) {
431
+ const tokens = searchTokens(query)
432
+ if (tokens.length === 0) return 0
433
+ const n = String(name ?? '').toLowerCase()
434
+ if (tokens.length === 1) return tokenScore(n, tokens[0])
435
+ let weakest = 0
436
+ for (const token of tokens) {
437
+ const score = tokenScore(n, token)
438
+ if (score === null) return null
439
+ if (score > weakest) weakest = score
440
+ }
441
+ return weakest
442
+ }
443
+
444
+ /**
445
+ * Rank of a model that is shown because its PROVIDER matched the query
446
+ * rather than its own name. Sorts after every name hit; the sort is stable,
447
+ * so models pulled in this way keep their catalog order.
448
+ */
449
+ const PROVIDER_MATCH = 3
450
+
451
+ // ---------------------------------------------------------------------
452
+ // Insufficient balance / credit / quota — the per-model strike state.
453
+ //
454
+ // The Host already classifies an exhausted account (rather than a
455
+ // transient rate limit) into the provider-neutral `QUOTA` and
456
+ // `ACCOUNT_QUOTA` failure codes, and a provider HTTP 402 maps to `QUOTA`
457
+ // (dsh-llm/src/error.ts `isQuotaExceededError`, dsh-llm-deepseek
458
+ // `providerError`). Those facts reach the Client on the durable
459
+ // `turn/end` event as `reason.error` = `{ code, message, status? }`, so the
460
+ // panel does not need a Host change: it folds the session event feed.
461
+ //
462
+ // The wording pass exists because a failure can be flattened to a
463
+ // transient-looking code with the provider's text kept in `message`. The
464
+ // provider-status code is NOT authoritative for the account state: several
465
+ // providers answer an exhausted balance with **HTTP 429** rather than 402 —
466
+ // e.g. `429: {"message":"insufficient balance","request_id":"…"}` — and the
467
+ // Host classifies that as `RATE_LIMIT`, which is normally transient. The
468
+ // message therefore has to decide in that case, so a 429 with balance
469
+ // wording strikes while a 429 that is a real rate limit does not.
470
+ //
471
+ // The wording pass is deliberately narrow: a bare "402"/"429" only counts
472
+ // when it reads as the leading status, never as a number inside another
473
+ // message, and every example below is taken from a real recorded session.
474
+ // ---------------------------------------------------------------------
475
+
476
+ /** Failure codes that mean "this account has no usable balance left". */
477
+ const INSUFFICIENT_CODES = new Set([
478
+ 'QUOTA', 'ACCOUNT_QUOTA', 'INSUFFICIENT_QUOTA', 'INSUFFICIENT_BALANCE', 'BUDGET_EXHAUSTED',
479
+ ])
480
+
481
+ /**
482
+ * Provider wording for an exhausted balance, credit, or account quota.
483
+ * Mirrors the Host's own classifier so the panel and the Chat's quota notice
484
+ * agree about what counts, plus the provider-specific shapes seen in real
485
+ * sessions (the Host's regexes are not importable from a browser bundle).
486
+ */
487
+ const INSUFFICIENT_TEXT = [
488
+ // "insufficient balance" / "insufficient_balance_error" / "insufficient credits".
489
+ /\binsufficient[\s_-]+(?:quota|balance|credits?|funds?|user[\s_-]+quota)\b/i,
490
+ // "quota exceeded" / "usage limit reached" / "quota_exceeded".
491
+ /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i,
492
+ // "exceeded your current quota".
493
+ /\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i,
494
+ // "balance exhausted" / "credits depleted" / "credit too low".
495
+ /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted|too[\s_-]+low)\b/i,
496
+ // "out of credits" / "out of budget".
497
+ /\bout[\s_-]+of[\s_-]+(?:credits?|budget|balance)\b/i,
498
+ // "no credits remaining" (OpenAI) / "no available balance".
499
+ /\bno[\s_-]+(?:available[\s_-]+)?(?:credits?|balance|quota)\b/i,
500
+ /\bno[\s_-]+credits?[\s_-]+remaining\b/i,
501
+ // "Not enough quota to pre-consume" (new-api gateways).
502
+ /\bnot[\s_-]+enough[\s_-]+quota\b/i,
503
+ // "doesn't have any credits or licenses yet" (xAI).
504
+ /\b(?:any|no)[\s_-]+credits?[\s_-]+or[\s_-]+licen[cs]es?\b/i,
505
+ // "Billing daily count exceeded" / billing hard limit.
506
+ /\bbilling[\s_-]+(?:daily[\s_-]+count[\s_-]+exceeded|hard[\s_-]+limit)\b/i,
507
+ // "Your requests have exceeded the quota" / "quota is exhausted" (Trae).
508
+ /\bquota[\s_-]+is[\s_-]+exhausted\b/i,
509
+ /\brequests?[\s_-]+have[\s_-]+exceeded[\s_-]+the[\s_-]+quota\b/i,
510
+ /\bpayment[\s_-]+required\b/i,
511
+ // Chinese provider wording: 额度已用尽 / 余额不足 / 预算已用完 / 欠费.
512
+ /额度不足|余额不足|额度已用尽|余额已用尽|欠费|账户余额|额度已耗尽/,
513
+ /(?:预算|额度)[^。;;]{0,12}(?:已用完|已用尽|不足|耗尽)/,
514
+ ]
515
+
516
+ /**
517
+ * Decide whether one recorded failure means the account ran out of
518
+ * balance rather than hitting a transient limit.
519
+ *
520
+ * A structured balance code is authoritative. A provider status of `402`
521
+ * ("payment required") is too. **`429` is NOT authoritative either way:**
522
+ * some providers report an exhausted balance with 429 and a body that says
523
+ * so (`429: {"message":"insufficient balance",…}`), which the Host
524
+ * classifies as `RATE_LIMIT`. For every other case the message decides, so
525
+ * a 429 carrying balance wording strikes while a genuine rate limit does
526
+ * not.
527
+ * @param {unknown} error - a `turn/end` `reason.error` (`{ code, message, status }`), or a thrown value.
528
+ * @returns {boolean} true only for an exhausted balance, credit, or quota.
529
+ */
530
+ function isInsufficientBalance(error) {
531
+ if (error === null || typeof error !== 'object') {
532
+ return typeof error === 'string' ? INSUFFICIENT_TEXT.some(pattern => pattern.test(error)) : false
533
+ }
534
+ const code = typeof error.code === 'string' ? error.code.toUpperCase() : ''
535
+ if (INSUFFICIENT_CODES.has(code)) return true
536
+ // A provider status of 402 means "payment required", which in practice is
537
+ // the account running dry. The structured `status` field is preferred; a
538
+ // code shaped `HTTP_402` carries the same fact.
539
+ if (error.status === 402 || code === 'HTTP_402') return true
540
+ // A 429 (or any other status) is decided by the text: providers use 429
541
+ // for BOTH a transient rate limit and an exhausted balance.
542
+ const message = typeof error.message === 'string' ? error.message : ''
543
+ if (INSUFFICIENT_TEXT.some(pattern => pattern.test(message))) return true
544
+ // `insufficient_user_quota` and friends can arrive only as a structured
545
+ // code inside the message body rather than as the failure code.
546
+ return /\binsufficient[\s_-]+user[\s_-]+quota\b/i.test(message)
547
+ }
548
+
549
+ /**
550
+ * Stable identity of one model route. The strike state is keyed by route,
551
+ * not by row, because the same model appears under both grouping modes and
552
+ * a provider may publish the same id under two groups.
553
+ * @param {unknown} provider - provider id.
554
+ * @param {unknown} model - provider-owned model id.
555
+ * @returns {string} the composite key.
556
+ */
557
+ function routeKeyOf(provider, model) {
558
+ return `${String(provider ?? '')}/${String(model ?? '')}`
559
+ }
560
+
561
+ /**
562
+ * Turn endings that PROVE the model route answered, so a standing
563
+ * insufficient-balance mark must end. A cancellation (`aborted`) or a turn
564
+ * closed before any request ran (`blocked`, and the synthetic
565
+ * `interrupted` / `forked` closers) proves nothing about the account — the
566
+ * model never answered — so those leave the mark alone.
567
+ */
568
+ const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
569
+
570
+ /**
571
+ * Fold ONE durable session event into the per-route insufficient-balance
572
+ * state. Pure and reference-preserving, so an unrelated event costs nothing
573
+ * and the caller can compare identity to decide whether to republish.
574
+ *
575
+ * A `turn/end` carrying a balance failure marks the route that request used.
576
+ * A later `turn/end` for the same route that proves the model answered
577
+ * without a balance failure clears the mark — a completed turn, or a turn
578
+ * that failed for another reason. A cancellation or a blocked turn clears
579
+ * nothing, because the model never answered. The route a `turn/end` belongs
580
+ * to is the latest `request/header` seen before it, which is exactly the
581
+ * model the failing request was dispatched to.
582
+ * @param {Map<string, boolean>} state - current marks, mutated in place.
583
+ * @param {unknown} event - one durable Session event (`{ type, data }`).
584
+ * @param {{ provider: string, model: string } | null} route - route resolved from the request header, or null.
585
+ * @returns {boolean} whether the state changed.
586
+ */
587
+ function foldInsufficient(state, event, route) {
588
+ if (event === null || typeof event !== 'object') return false
589
+ if (event.type !== 'turn/end' || route === null) return false
590
+ const reason = event.data?.reason
591
+ if (reason === null || typeof reason !== 'object') return false
592
+ const key = routeKeyOf(route.provider, route.model)
593
+ if (reason.kind === 'error' && isInsufficientBalance(reason.error)) {
594
+ if (state.get(key) === true) return false
595
+ state.set(key, true)
596
+ return true
597
+ }
598
+ if (!CLEARING_TURN_ENDS.has(reason.kind)) return false
599
+ if (state.get(key) !== true) return false
600
+ state.delete(key)
601
+ return true
602
+ }
603
+
604
+ /**
605
+ * Resolve the model route a request header names. The header's `config` is
606
+ * an `LlmCallConfig` (`{ provider, model, ... }`).
607
+ * @param {unknown} event - one durable Session event.
608
+ * @returns {{ provider: string, model: string } | null} the route, or null when this event is not a usable header.
609
+ */
610
+ function routeOfHeader(event) {
611
+ if (event === null || typeof event !== 'object' || event.type !== 'request/header') return null
612
+ const config = event.data?.header?.config
613
+ if (config === null || typeof config !== 'object') return null
614
+ if (typeof config.provider !== 'string' || typeof config.model !== 'string') return null
615
+ return { provider: config.provider, model: config.model }
616
+ }
617
+
618
+ /**
619
+ * Every searchable face of a provider-authored text: a plain string is one
620
+ * face, a localized object contributes each language it carries. Provider
621
+ * names arrive localized (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the
622
+ * search has to see all of them — matching only the English face would make
623
+ * the Chinese name unsearchable.
624
+ * @param {unknown} value - a string, or a localized record of strings.
625
+ * @returns {string[]} lowercased, non-empty faces.
626
+ */
627
+ function textFaces(value) {
628
+ if (typeof value === 'string') return [value.toLowerCase()].filter(text => text !== '')
629
+ if (value === null || typeof value !== 'object') return []
630
+ const faces = []
631
+ for (const face of Object.values(value)) {
632
+ if (typeof face !== 'string') continue
633
+ const text = face.toLowerCase()
634
+ if (text !== '') faces.push(text)
635
+ }
636
+ return faces
637
+ }
638
+
639
+ /**
640
+ * Match the query against the provider itself — its id, its catalog name,
641
+ * and the label its section draws. A hit means "this vendor was asked for",
642
+ * so the whole catalog of that provider is listed: typing `dou` finds the
643
+ * `豆包(doubao)` provider and every model under it, including the ones whose
644
+ * own names never contain the query.
645
+ *
646
+ * A multi-token query matches the provider when every token lands in SOME
647
+ * face of the same provider, so `mod inte` finds a provider named
648
+ * `ModelScope International` even though those words are never contiguous.
649
+ * Tokens may land in different faces — the id answers `mod`, the localized
650
+ * name answers `inte`.
651
+ *
652
+ * Only prefix and substring are considered per token. A subsequence pass —
653
+ * which `matchScore` allows for model names — would let a two-letter query
654
+ * light up every provider in the directory, which is noise rather than a
655
+ * match.
656
+ * @param {{id?: string, name?: unknown}} group - one provider entry.
657
+ * @param {string} query - raw search text.
658
+ * @param {unknown} [title] - the label this provider's section draws.
659
+ * @returns {0|1|null} 0 for a prefix hit, 1 for a substring hit, else null.
660
+ */
661
+ function providerScore(group, query, title) {
662
+ const tokens = searchTokens(query)
663
+ if (tokens.length === 0) return null
664
+ const texts = [...textFaces(group?.id), ...textFaces(group?.name), ...textFaces(title)]
665
+ if (texts.length === 0) return null
666
+ /** The best rank one token reaches across every face. */
667
+ const rankOf = token => {
668
+ let best = null
669
+ for (const text of texts) {
670
+ const rank = text.startsWith(token) ? 0 : text.includes(token) ? 1 : null
671
+ if (rank === null) continue
672
+ if (best === null || rank < best) best = rank
673
+ if (best === 0) return 0
674
+ }
675
+ return best
676
+ }
677
+ let weakest = 0
678
+ for (const token of tokens) {
679
+ const rank = rankOf(token)
680
+ if (rank === null) return null
681
+ if (rank > weakest) weakest = rank
682
+ }
683
+ return weakest
684
+ }
685
+
686
+ // ---------------------------------------------------------------------
687
+ // Model families: hard-coded dictionary → id-derived guess → Others.
688
+ // Families stay flat; Others always sorts last. A family is a BRAND, and
689
+ // a *registered* brand keeps its GENERATIONS apart: `hy3` is `HY3`,
690
+ // `hy4-preview`/`hy4free`/`hy4std` are `HY4`, while a bare `hy` is `HY`.
691
+ // Unregistered brands still collapse their version tail (`acme4-turbo`
692
+ // and `acme-7b` are one `Acme`). Dictionary families never split.
693
+ // ---------------------------------------------------------------------
694
+ const FAMILY_DICTIONARY = [
695
+ ['DeepSeek', ['deepseek']],
696
+ ['Grok', ['grok']],
697
+ ['Opus', ['opus']],
698
+ ['Haiku', ['haiku']],
699
+ ['Sonnet', ['sonnet']],
700
+ ['Claude', ['claude']],
701
+ ['Qwen', ['qwen', 'qwq']],
702
+ ['GLM', ['glm', 'chatglm']],
703
+ ['Kimi', ['kimi', 'moonshot']],
704
+ ['GPT', ['gpt', 'chatgpt', 'o1-', 'o3-', 'o4-']],
705
+ ['Gemini', ['gemini', 'gemma']],
706
+ ['Llama', ['llama']],
707
+ ['Mistral', ['mistral', 'mixtral', 'codestral']],
708
+ ['Doubao', ['doubao', 'seed-']],
709
+ ['MiniMax', ['minimax', 'abab']],
710
+ ['Hunyuan', ['hunyuan']],
711
+ ['ERNIE', ['ernie']],
712
+ ['Yi', ['yi-', '01-ai']],
713
+ ['Phi', ['phi-']],
714
+ ['Command', ['command-r']],
715
+ ['Nova', ['nova-']],
716
+ ['Step', ['step-']],
717
+ ]
718
+ const OTHERS = 'Others'
719
+ const FAMILY_ALIASES = new Map([
720
+ ['gpt', 'GPT'], ['glm', 'GLM'], ['qwen', 'Qwen'], ['kimi', 'Kimi'],
721
+ ])
722
+ const VERSION_ONLY = /^(v|ver|r)?\d+([.-]\d+)*$/i
723
+ /**
724
+ * A brand token plus its version tail: `hy3`, `hy4-preview`, `hy4free`,
725
+ * `qwen2.5`. Group 1 is the brand, group 2 the leading generation digit(s)
726
+ * (captured so registered brands can keep generations apart), group 3 the
727
+ * rest of the variant tail.
728
+ */
729
+ const BRAND_WITH_VERSION = /^([a-z][a-z]*?)(\d+)([\w.-]*)$/i
730
+ /**
731
+ * Registered brands, mapped to their exact heading casing. Sort with the
732
+ * dictionary families instead of after them. `HY` is the case that
733
+ * prompted this: its ids (`hy3`, `hy4-preview`, `hy4free`) share no name
734
+ * needle, and the brand is an acronym the generic title-casing would draw
735
+ * as `Hy`. A registered brand's family is `BRAND + generation` when the id
736
+ * carries a generation digit (so `hy3` → `HY3`, `hy4free` → `HY4`), and
737
+ * the bare brand otherwise (`hy` → `HY`). Dictionary families are never
738
+ * split by generation.
739
+ */
740
+ const FAMILY_BRANDS = new Map([['hy', 'HY']])
741
+ const FAMILY_ORDER = [
742
+ ...FAMILY_DICTIONARY.map(entry => entry[0]),
743
+ ...FAMILY_BRANDS.values(),
744
+ ]
745
+
746
+ /**
747
+ * Split one versioned id token into its brand / generation / variant tail.
748
+ * @param {string} token - one lowercased id segment.
749
+ * @returns {{brand: string, generation: string, variant: string} | null}
750
+ */
751
+ function versionParts(token) {
752
+ const hit = BRAND_WITH_VERSION.exec(token)
753
+ if (hit === null) return null
754
+ const brand = hit[1]
755
+ return brand.length < 2 ? null : { brand, generation: hit[2], variant: hit[3] }
756
+ }
757
+
758
+ /**
759
+ * The brand part of one versioned id token, or '' when the token carries
760
+ * no version tail.
761
+ * @param {string} token - one lowercased id segment.
762
+ * @returns {string} the lowercased brand, or ''.
763
+ */
764
+ function brandOf(token) {
765
+ return versionParts(token)?.brand ?? ''
766
+ }
767
+
768
+ /**
769
+ * Resolve one model's family.
770
+ * @param {{id: string, name: string}} model - catalog entry.
771
+ * @returns {string} a flat family name; never empty.
772
+ */
773
+ function familyOf(model) {
774
+ const haystack = `${model.id} ${model.name}`.toLowerCase()
775
+ for (const entry of FAMILY_DICTIONARY) {
776
+ if (entry[1].some(needle => haystack.includes(needle))) return entry[0]
777
+ }
778
+ // Id-derived guess. The provider prefix is dropped, then every remaining
779
+ // segment is checked for a registered brand, so an id that prefixes the
780
+ // brand with its provider (`codebuddy-hy3`) still lands in `HY3`, and a
781
+ // bare brand id (`hy`) resolves to `HY` rather than the generic casing.
782
+ const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
783
+ if (segments.length === 0) return OTHERS
784
+ for (const segment of segments) {
785
+ const lower = segment.toLowerCase()
786
+ const parts = versionParts(lower)
787
+ const known = FAMILY_BRANDS.get(lower)
788
+ ?? FAMILY_BRANDS.get(parts?.brand ?? '')
789
+ if (known !== undefined) {
790
+ // A registered brand keeps its generations distinct: hy3 → HY3,
791
+ // hy4free → HY4. A bare brand with no generation stays `HY`.
792
+ return parts === null ? known : `${known}${parts.generation}`
793
+ }
794
+ }
795
+ const token = segments[0].toLowerCase()
796
+ if (token.length < 2 || VERSION_ONLY.test(token)) return OTHERS
797
+ const alias = FAMILY_ALIASES.get(token)
798
+ if (alias !== undefined) return alias
799
+ // An unregistered versioned segment collapses onto its brand, so
800
+ // `acme4-turbo` and `acme-7b` share one heading instead of two.
801
+ const brand = brandOf(token)
802
+ const stem = brand === '' ? token : brand
803
+ return stem.charAt(0).toUpperCase() + stem.slice(1)
804
+ }
805
+
806
+ /**
807
+ * Sort rank of a family. Registered-brand generations (`HY3`, `HY4`, …)
808
+ * rank with their bare brand (`HY`) and order by generation number, so a
809
+ * new generation lands next to its siblings instead of at the end.
810
+ */
811
+ function familyRank(family) {
812
+ if (family === OTHERS) return Number.MAX_SAFE_INTEGER
813
+ const known = FAMILY_ORDER.indexOf(family)
814
+ if (known !== -1) return known
815
+ for (const [brand, heading] of FAMILY_BRANDS) {
816
+ if (!family.startsWith(heading) || family.length === heading.length) continue
817
+ const generation = family.slice(heading.length)
818
+ if (!/^\d+$/.test(generation)) continue
819
+ return FAMILY_ORDER.indexOf(heading) + Number(generation) / 1000
820
+ }
821
+ return FAMILY_ORDER.length
822
+ }
823
+
824
+ /**
825
+ * A short variant label taken from a model id, used only to tell several
826
+ * models of ONE provider inside ONE family apart. The family prefix is
827
+ * stripped from every id segment, so hy4free → "free", hy4std → "std",
828
+ * hy4-preview → "preview", hy4flash-exp1 → "flash exp1", hy3-x → "x",
829
+ * and a bare hy3 → "". Pure version numbers are dropped. Returns '' when
830
+ * the id carries nothing beyond the family heading itself.
831
+ * @param {{id: string}} model - catalog entry.
832
+ * @param {string} family - the resolved family heading.
833
+ * @returns {string} a short variant label, or ''.
834
+ */
835
+ function idVariantOf(model, family) {
836
+ const familyLower = family.toLowerCase()
837
+ const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
838
+ const words = []
839
+ for (const raw of segments) {
840
+ const segment = raw.toLowerCase()
841
+ // Remove the family heading from inside a glued segment: hy4free →
842
+ // free, hy4flash → flash. A segment equal to the family drops to ''.
843
+ let word = segment.startsWith(familyLower) ? segment.slice(familyLower.length) : segment
844
+ word = word.replace(/^[-._]+/, '')
845
+ if (word === '' || /^\d+(\.\d+)?$/.test(word)) continue
846
+ words.push(...word.split(/[-._]+/).filter(Boolean))
847
+ }
848
+ return words.filter(word => !/^\d+$/.test(word)).join(' ')
849
+ }
850
+
851
+ /**
852
+ * The distinguishing part of one model for a family-grouped row: the id's
853
+ * variant tail first, then any catalog-name words that are not just the
854
+ * family heading or a bare version number. Returns '' when the model adds
855
+ * nothing beyond what the family heading already says.
856
+ * @param {{id: string, name: string}} model - catalog entry.
857
+ * @param {string} family - the resolved family heading.
858
+ * @returns {string} a short, non-family supplement; possibly ''.
859
+ */
860
+ function rowVariantOf(model, family) {
861
+ const fromId = idVariantOf(model, family)
862
+ if (fromId !== '') return fromId
863
+ const familyWords = new Set(family.toLowerCase().split(/[\s-]+/).filter(Boolean))
864
+ return String(model.name ?? '').split(/[\s._-]+/).filter(word => {
865
+ const lower = word.toLowerCase()
866
+ return word !== '' && !familyWords.has(lower) && !/^\d+(\.\d+)?$/.test(lower)
867
+ }).join(' ')
868
+ }
869
+
870
+ // ---------------------------------------------------------------------
871
+ // Column planning
872
+ // ---------------------------------------------------------------------
873
+ /** One indivisible placement unit: a group title with its first model, or a lone model row. */
874
+ function buildUnits(sections, rowH, headH) {
875
+ const units = []
876
+ for (const section of sections) {
877
+ if (section.models.length === 0) continue
878
+ units.push({
879
+ height: headH + ROW_GAP + rowH,
880
+ blocks: [
881
+ { kind: 'title', key: `t:${section.key}`, label: section.title },
882
+ { kind: 'row', key: `r:${section.key}:0`, entry: section.models[0] },
883
+ ],
884
+ })
885
+ for (let index = 1; index < section.models.length; index += 1) {
886
+ units.push({
887
+ height: rowH,
888
+ blocks: [{ kind: 'row', key: `r:${section.key}:${index}`, entry: section.models[index] }],
889
+ })
890
+ }
891
+ }
892
+ return units
893
+ }
894
+
895
+ function columnHeight(units) {
896
+ if (units.length === 0) return 0
897
+ return units.reduce((sum, unit) => sum + unit.height, 0) + ROW_GAP * (units.length - 1)
898
+ }
899
+
900
+ /**
901
+ * Flow units into `count` columns, then migrate the last column's deficit
902
+ * back into its neighbour. A group title never separates from its first
903
+ * model because both live in one unit.
904
+ */
905
+ function packColumns(units, count) {
906
+ const columns = Array.from({ length: count }, () => [])
907
+ const target = columnHeight(units) / count
908
+ let index = 0
909
+ for (let column = 0; column < count && index < units.length; column += 1) {
910
+ const isLast = column === count - 1
911
+ let used = 0
912
+ while (index < units.length) {
913
+ const unit = units[index]
914
+ const add = used === 0 ? unit.height : unit.height + ROW_GAP
915
+ if (!isLast && used > 0 && used + add > target) break
916
+ columns[column].push(unit)
917
+ used += add
918
+ index += 1
919
+ }
920
+ }
921
+ while (index < units.length) {
922
+ columns[count - 1].push(units[index])
923
+ index += 1
924
+ }
925
+ // Balance the final pair only when it actually narrows the spread.
926
+ if (count > 1) {
927
+ for (let guard = 0; guard < units.length; guard += 1) {
928
+ const left = columns[count - 2]
929
+ const right = columns[count - 1]
930
+ if (left.length <= 1) break
931
+ const leftHeight = columnHeight(left)
932
+ const rightHeight = columnHeight(right)
933
+ if (leftHeight - rightHeight < BALANCE_THRESHOLD) break
934
+ const unit = left[left.length - 1]
935
+ const before = Math.max(leftHeight, rightHeight)
936
+ const after = Math.max(columnHeight(left.slice(0, -1)), columnHeight([...right, unit]))
937
+ if (after >= before) break
938
+ left.pop()
939
+ right.push(unit)
940
+ }
941
+ }
942
+ return columns
943
+ }
944
+
945
+ /** Smallest column count (1–3) whose tallest column fits; otherwise 3 + scroll. */
946
+ function planColumns(units, available) {
947
+ if (units.length === 0) return { count: 1, columns: [[]] }
948
+ for (let count = 1; count <= 3; count += 1) {
949
+ const columns = packColumns(units, count)
950
+ if (Math.max(...columns.map(columnHeight)) <= available) return { count, columns }
951
+ }
952
+ return { count: 3, columns: packColumns(units, 3) }
953
+ }
954
+
955
+ // ---------------------------------------------------------------------
956
+ // Dictionaries (this namespace owns the panel's own copy; the teamwork
957
+ // row prefers the host's `agent-team` wording when it is registered).
958
+ //
959
+ // The namespace id is minted per module generation. The locale registry
960
+ // keeps a namespace's slot after its disposer runs, and a re-registration
961
+ // of the same id throws — which would cost the seat its copy on the
962
+ // enable/disable/enable path. A fresh id makes every generation's
963
+ // registration independent, and the previous one is dropped with its own
964
+ // disposer.
965
+ // ---------------------------------------------------------------------
966
+ const NS = `modelFlatmap-${Math.random().toString(36).slice(2, 10)}`
967
+
968
+ // ---------------------------------------------------------------------
969
+ // Grouping persistence — across sessions and workgroups.
970
+ //
971
+ // The `按 Provider / 按模型家族` choice is a user preference that must
972
+ // survive a reload, a new conversation, and a workgroup switch. On DSH
973
+ // Desktop the Web GUI runs on a stable origin (a fixed bundled origin in
974
+ // production, `http://127.0.0.1:56668` in development), so `localStorage`
975
+ // — origin-scoped, not session-scoped — is exactly the right store: it
976
+ // persists across every one of those boundaries. DSH's own client store
977
+ // (`@deepseek-ai/dsh-client-store`) is the same `localStorage` wrapper
978
+ // under the hood, but importing an internal Harness client package is
979
+ // forbidden by the UI rules (it changes without notice and a throw blanks
980
+ // the seat). We therefore use `localStorage` directly — the public,
981
+ // self-contained mechanism the host endorses underneath.
982
+ //
983
+ // The store is read and written lazily, and every access degrades: when
984
+ // `localStorage` is unavailable (private mode, sandboxed frame) the choice
985
+ // still works for the lifetime of the tab, falling back to the in-memory
986
+ // default rather than throwing.
987
+ // ---------------------------------------------------------------------
988
+ const GROUPING_KEY = 'dshmf:grouping'
989
+ const GROUPING_DEFAULT = 'provider'
990
+ /**
991
+ * Read the persisted grouping, or the default when nothing valid was stored.
992
+ * @returns `'provider'` or `'family'`.
993
+ */
994
+ function readGrouping() {
995
+ try {
996
+ if (typeof localStorage === 'undefined') return GROUPING_DEFAULT
997
+ const value = localStorage.getItem(GROUPING_KEY)
998
+ return value === 'provider' || value === 'family' ? value : GROUPING_DEFAULT
999
+ } catch {
1000
+ return GROUPING_DEFAULT
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Persist one grouping choice. A write failure (quota, disabled storage)
1005
+ * is swallowed: the in-memory state still drives the current tab.
1006
+ * @param {'provider'|'family'} value - the choice to persist.
1007
+ */
1008
+ function writeGrouping(value) {
1009
+ try {
1010
+ if (typeof localStorage === 'undefined') return
1011
+ localStorage.setItem(GROUPING_KEY, value)
1012
+ } catch {
1013
+ /* storage unavailable — keep the in-memory choice only */
1014
+ }
1015
+ }
1016
+
1017
+ const dict = {
1018
+ zh: {
1019
+ 'provider.account': 'DeepSeek 账号',
1020
+ 'search.placeholder': '搜索模型…',
1021
+ 'search.clear': '清除搜索',
1022
+ 'group.provider': '按 Provider',
1023
+ 'group.family': '按模型家族',
1024
+ 'group.aria': '模型分组方式',
1025
+ 'empty.models': '暂无可用模型',
1026
+ 'empty.search': '无匹配模型',
1027
+ 'status.loading': '正在刷新模型列表…',
1028
+ 'trigger.fallback': '请选择模型',
1029
+ 'trigger.loading': '正在加载模型…',
1030
+ 'trigger.aria': '选择模型,当前 {model}',
1031
+ 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
1032
+ 'panel.aria': '模型与推理等级',
1033
+ 'effort.default': 'Default',
1034
+ 'effort.label': '推理等级',
1035
+ 'effort.hint': '调整模型的思考强度:档位越高,思考越深入,但速度更慢、token 开销更大。',
1036
+ 'team.label': '启用智能体团队协作',
1037
+ 'team.description': '通过共享任务板协调多个智能体的分工与进度,复杂任务表现更佳,token 开销也相应更高。',
1038
+ 'team.failed': '无法确认智能体团队协作设置已保存,请重试:{message}',
1039
+ 'team.disable.title': '确认停用智能体团队协作?',
1040
+ 'team.disable.description': '对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。',
1041
+ 'team.disable.warning': '停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。',
1042
+ 'team.disable.cancel': '取消',
1043
+ 'team.disable.confirm': '确认停用',
1044
+ 'failure.group': '{name} 加载失败:{message}',
1045
+ 'error.action': '模型操作失败:{message}',
1046
+ 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他正在运行的 DSH 后重试。',
1047
+ },
1048
+ en: {
1049
+ 'provider.account': 'DeepSeek Account',
1050
+ 'search.placeholder': 'Search models…',
1051
+ 'search.clear': 'Clear search',
1052
+ 'group.provider': 'By provider',
1053
+ 'group.family': 'By family',
1054
+ 'group.aria': 'Group models by',
1055
+ 'empty.models': 'No models available',
1056
+ 'empty.search': 'No matching models',
1057
+ 'status.loading': 'Refreshing model list…',
1058
+ 'trigger.fallback': 'Select model',
1059
+ 'trigger.loading': 'Loading models…',
1060
+ 'trigger.aria': 'Select model, current {model}',
1061
+ 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
1062
+ 'panel.aria': 'Model and reasoning effort',
1063
+ 'effort.default': 'Default',
1064
+ 'effort.label': 'Reasoning effort',
1065
+ 'effort.hint': 'Adjusts how deeply the model thinks: higher levels reason more deeply, but are slower and cost more tokens.',
1066
+ 'team.label': 'Enable agent teamwork',
1067
+ 'team.description': 'Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs.',
1068
+ 'team.failed': 'Could not confirm the agent teamwork setting was saved. Try again: {message}',
1069
+ 'team.disable.title': 'Disable agent teamwork?',
1070
+ 'team.disable.description': 'Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions.',
1071
+ 'team.disable.warning': 'Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task.',
1072
+ 'team.disable.cancel': 'Cancel',
1073
+ 'team.disable.confirm': 'Disable',
1074
+ 'failure.group': '{name} failed to load: {message}',
1075
+ 'error.action': 'Model operation failed: {message}',
1076
+ 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
1077
+ },
1078
+ }
1079
+
1080
+ const EMPTY_STATE = {
1081
+ current: null,
1082
+ retainedEffort: undefined,
1083
+ routable: null,
1084
+ groups: [],
1085
+ failures: [],
1086
+ status: 'idle',
1087
+ pending: null,
1088
+ error: null,
1089
+ }
1090
+
1091
+ /**
1092
+ * Stand-in store used before a Session's directory resolves. It never
1093
+ * notifies, so `useSyncExternalStore` keeps a constant hook shape while the
1094
+ * real store is swapped in — the component therefore always renders a
1095
+ * trigger, never `null`.
1096
+ */
1097
+ const EMPTY_STORE = {
1098
+ subscribe: () => () => {},
1099
+ getSnapshot: () => EMPTY_STATE,
1100
+ }
1101
+
1102
+ /**
1103
+ * Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
1104
+ * stable for every seat that has no Session feed, so an unmarked panel never
1105
+ * re-renders because of this feature.
1106
+ */
1107
+ const EMPTY_INSUFFICIENT_MARKS = new Map()
1108
+
1109
+ /**
1110
+ * The one empty snapshot object. It MUST be a single shared instance:
1111
+ * `useSyncExternalStore` compares snapshots by identity, so returning a
1112
+ * fresh `{ marks }` on every call would look like a change on every render
1113
+ * and loop.
1114
+ */
1115
+ const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
1116
+
1117
+ /**
1118
+ * Stand-in mark face used before a Session's feed resolves (or when the face
1119
+ * degraded). It never notifies and always answers the same empty snapshot.
1120
+ */
1121
+ const EMPTY_INSUFFICIENT = {
1122
+ subscribe: () => () => {},
1123
+ getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
1124
+ dispose: () => {},
1125
+ }
1126
+
1127
+ // ---------------------------------------------------------------------
1128
+ // Component
1129
+ // ---------------------------------------------------------------------
1130
+ /**
1131
+ * Render the flat model picker seat.
1132
+ * @param {object} props - slot standard props + owner `locked` + injected face.
1133
+ */
1134
+ function ModelFlatMap(props) {
1135
+ const {
1136
+ locked, sessionId, t,
1137
+ available, directory, load, select, resolveText, resolveDirectory,
1138
+ teamAvailable, setTeamMode, useProjection, useSession, useSessions,
1139
+ insufficient,
1140
+ } = props
1141
+
1142
+ // The store may arrive after the first render (the Session binds later),
1143
+ // so resolve it on each render and fall back to a stable empty store.
1144
+ const resolved = directory === undefined && typeof resolveDirectory === 'function'
1145
+ ? resolveDirectory()
1146
+ : undefined
1147
+ const store = directory ?? resolved?.store ?? EMPTY_STORE
1148
+
1149
+ const subscribe = useCallback(listener => store.subscribe(listener), [store])
1150
+ const getSnapshot = useCallback(() => store.getSnapshot(), [store])
1151
+ const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
1152
+
1153
+ // Per-model insufficient-balance marks, folded from the Session's own
1154
+ // durable event feed by the face. The fallback keeps the hook shape stable
1155
+ // for a composition that omits the face.
1156
+ const markFace = insufficient ?? EMPTY_INSUFFICIENT
1157
+ const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
1158
+ const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
1159
+ const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
1160
+
1161
+ // The mark face observes the Session feed for as long as this seat is
1162
+ // mounted. Detaching on unmount cannot lose a mark: a remount refolds from
1163
+ // the full durable window, so the state is rebuilt rather than remembered.
1164
+ useEffect(() => () => { markFace.dispose() }, [markFace])
1165
+
1166
+ const [open, setOpen] = useState(false)
1167
+ const [query, setQuery] = useState('')
1168
+ // The grouping choice is restored from storage so it persists across
1169
+ // sessions and workgroups, not just within one panel mount.
1170
+ const [grouping, setGrouping] = useState(readGrouping())
1171
+ const [selectionFocus, setSelectionFocus] = useState(false)
1172
+ const [metrics, setMetrics] = useState({ rowH: 26, headH: 20, fixed: 0, fixedCount: 0, box: null })
1173
+ const [teamPending, setTeamPending] = useState(false)
1174
+ const [teamFailure, setTeamFailure] = useState(null)
1175
+ const [confirming, setConfirming] = useState(false)
1176
+ const [replan, setReplan] = useState(0)
1177
+
1178
+ const rootRef = useRef(null)
1179
+ const triggerRef = useRef(null)
1180
+ const panelRef = useRef(null)
1181
+ const searchRef = useRef(null)
1182
+ const probeRowRef = useRef(null)
1183
+ const probeHeadRef = useRef(null)
1184
+ const headRef = useRef(null)
1185
+ const errorRef = useRef(null)
1186
+ const reasonRef = useRef(null)
1187
+ const teamRef = useRef(null)
1188
+ const footerRef = useRef(null)
1189
+ const teamErrorRef = useRef(null)
1190
+ const confirmRef = useRef(null)
1191
+ const id = useId()
1192
+
1193
+ // Standard props always carry these three hooks for a session-scope slot;
1194
+ // the fallbacks keep the component renderable (rather than throwing) in a
1195
+ // composition that omits one, and they are plain values so the hook order
1196
+ // below never changes.
1197
+ const useProjectionSafe = typeof useProjection === 'function' ? useProjection : () => undefined
1198
+ const useSessionSafe = typeof useSession === 'function' ? useSession : () => null
1199
+ const useSessionsSafe = typeof useSessions === 'function' ? useSessions : () => undefined
1200
+
1201
+ const teamMode = useProjectionSafe('agentTeam')
1202
+ const modelSelection = useProjectionSafe('modelSelection')
1203
+ // Both hooks may hand back `undefined` before the Session binds, so the
1204
+ // selectors never assume an object.
1205
+ const addressedChild = useSessionSafe(
1206
+ snapshot => snapshot?.subagent != null,
1207
+ ) === true
1208
+ const listedChild = useSessionsSafe(
1209
+ listener => listener?.byId?.[sessionId]?.origin === 'subagent',
1210
+ ) === true
1211
+
1212
+ /**
1213
+ * Run a pure derivation and degrade instead of throwing.
1214
+ *
1215
+ * A render-time throw leaves the cell to the boundary, and a shadowing
1216
+ * entry that throws twice is worth avoiding: one malformed catalog field
1217
+ * must not cost the whole control. The first failure per label is logged.
1218
+ * @param label - which derivation failed, for the console.
1219
+ * @param run - the derivation.
1220
+ * @param fallback - the value to use when it throws.
1221
+ * @returns the derived value, or the fallback.
1222
+ */
1223
+ const derive = (label, run, fallback) => guarded(label, run, fallback)
1224
+
1225
+ // Provider order matches the shipped picker: account, official, catalog order.
1226
+ const groups = useMemo(() => derive('grouping providers', () => {
1227
+ const ranked = state.groups.map((group, index) => ({ group, index }))
1228
+ const rankOf = value => (value.group.id === 'deepseek-account' ? 0
1229
+ : value.group.id === 'deepseek-official' ? 1 : 2)
1230
+ ranked.sort((left, right) => rankOf(left) - rankOf(right) || left.index - right.index)
1231
+ return ranked.map(entry => entry.group)
1232
+ }, []), [state.groups])
1233
+
1234
+ /** Provider or family sections, search-filtered; empty groups never appear. */
1235
+ const sections = useMemo(() => derive('grouping models', () => {
1236
+ const out = []
1237
+ /**
1238
+ * The provider's display label. Catalog names arrive localized
1239
+ * (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the label is resolved
1240
+ * once here and reused as the section title, the search face, and the
1241
+ * per-row provider tag. The account provider has no catalog name of
1242
+ * its own and borrows the localized "DeepSeek Account" wording.
1243
+ * @param {{id: string, name: unknown}} group - one provider entry.
1244
+ * @returns {string} a display-ready label; never empty.
1245
+ */
1246
+ const providerLabelOf = group => {
1247
+ if (group.id === 'deepseek-account') return t('provider.account')
1248
+ const resolved = resolveText === undefined ? undefined : resolveText(group.name)
1249
+ if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
1250
+ const fallback = fallbackText(group.name)
1251
+ return fallback.trim() === '' ? group.id : fallback
1252
+ }
1253
+ /**
1254
+ * A provider whose own name or id matches the query keeps its whole
1255
+ * catalog: `dou` shows the `豆包(doubao)` provider with every model it
1256
+ * offers. Models matched by their own name still rank first, so a
1257
+ * provider-wide hit only widens the result, never reorders it.
1258
+ */
1259
+ const providerHitOf = group => providerScore(group, query, providerLabelOf(group))
1260
+ /** One model's rank, or null when neither it nor its provider matched. */
1261
+ const scoreOf = (model, providerHit) => {
1262
+ const byName = matchScore(model.name, query)
1263
+ if (byName !== null) return byName
1264
+ return providerHit ? PROVIDER_MATCH : null
1265
+ }
1266
+ if (grouping === 'provider') {
1267
+ for (const group of groups) {
1268
+ const providerHit = providerHitOf(group) !== null
1269
+ const providerLabel = providerLabelOf(group)
1270
+ const scored = []
1271
+ for (const model of group.models) {
1272
+ const score = scoreOf(model, providerHit)
1273
+ if (score !== null) {
1274
+ scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
1275
+ }
1276
+ }
1277
+ if (scored.length === 0) continue
1278
+ scored.sort((left, right) => left.score - right.score)
1279
+ out.push({
1280
+ key: group.id,
1281
+ title: providerLabel,
1282
+ models: scored.map(item => item.entry),
1283
+ })
1284
+ }
1285
+ return out
1286
+ }
1287
+ const buckets = new Map()
1288
+ for (const group of groups) {
1289
+ const providerHit = providerHitOf(group) !== null
1290
+ const providerLabel = providerLabelOf(group)
1291
+ for (const model of group.models) {
1292
+ const score = scoreOf(model, providerHit)
1293
+ if (score === null) continue
1294
+ const family = familyOf(model)
1295
+ let bucket = buckets.get(family)
1296
+ if (bucket === undefined) {
1297
+ bucket = { key: family, title: family, scored: [] }
1298
+ buckets.set(family, bucket)
1299
+ }
1300
+ bucket.scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
1301
+ }
1302
+ }
1303
+ const families = [...buckets.values()]
1304
+ families.sort((left, right) => familyRank(left.title) - familyRank(right.title)
1305
+ || left.title.localeCompare(right.title))
1306
+ for (const family of families) {
1307
+ family.scored.sort((left, right) => left.score - right.score)
1308
+ // Under family grouping each row's primary text is its provider and
1309
+ // the family name is NOT repeated (the heading already says it). Only
1310
+ // when ONE provider offers several models in this family do the rows
1311
+ // need a short distinguishing tail, so the same provider name is not
1312
+ // printed twice with no way to tell the models apart.
1313
+ const byProvider = new Map()
1314
+ for (const item of family.scored) {
1315
+ const list = byProvider.get(item.entry.providerId) ?? []
1316
+ list.push(item)
1317
+ byProvider.set(item.entry.providerId, list)
1318
+ }
1319
+ for (const list of byProvider.values()) {
1320
+ if (list.length <= 1) continue
1321
+ const used = new Map()
1322
+ for (const item of list) {
1323
+ // An empty variant is intentional: the plain/base model of this
1324
+ // family carries no tag at all. Only a real collision (two rows
1325
+ // with the same non-empty variant, or two rows both empty) needs
1326
+ // a distinguishing fallback — the model id, which is unique.
1327
+ const variant = rowVariantOf(item.entry.model, family.title)
1328
+ item.entry.rowVariant = variant
1329
+ used.set(variant, (used.get(variant) ?? 0) + 1)
1330
+ }
1331
+ if ([...used.values()].some(count => count > 1)) {
1332
+ for (const item of list) {
1333
+ if (used.get(item.entry.rowVariant) > 1) {
1334
+ item.entry.rowVariant = String(item.entry.model.id ?? '')
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ out.push({ key: family.key, title: family.title, models: family.scored.map(item => item.entry) })
1340
+ }
1341
+ return out
1342
+ }, []), [grouping, groups, query, t, resolveText])
1343
+
1344
+ const totalModels = useMemo(
1345
+ () => groups.reduce((sum, group) => sum + group.models.length, 0),
1346
+ [groups],
1347
+ )
1348
+
1349
+ const currentModel = useMemo(() => derive('resolving the current model', () => {
1350
+ if (state.current === null) return null
1351
+ for (const group of groups) {
1352
+ if (group.id !== state.current.provider) continue
1353
+ for (const model of group.models) {
1354
+ if (model.id === state.current.model) return { group, model }
1355
+ }
1356
+ }
1357
+ return null
1358
+ }, null), [groups, state.current])
1359
+
1360
+ const reasoning = currentModel === undefined || currentModel === null
1361
+ ? undefined
1362
+ : currentModel.model.reasoning
1363
+ const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
1364
+ const effortLabel = reasoning === undefined
1365
+ ? state.retainedEffort
1366
+ : effectiveEffort === undefined
1367
+ ? t('effort.default')
1368
+ : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
1369
+ const effortChoices = reasoning === undefined ? [] : reasoning.efforts
1370
+ const busy = state.pending !== null
1371
+
1372
+ // ---- measurement: probe metrics + fixed-section heights
1373
+ // The reasoning group and the teamwork row now share ONE footer flex
1374
+ // container, so measure that container once instead of the two children
1375
+ // separately: when they sit on the same line the two heights overlap,
1376
+ // and summing them would over-subtract from the grid's budget.
1377
+ useLayoutEffect(() => {
1378
+ if (!open) return
1379
+ const rowH = probeRowRef.current?.getBoundingClientRect().height ?? 26
1380
+ const headH = probeHeadRef.current?.getBoundingClientRect().height ?? 20
1381
+ const heights = [headRef, errorRef, footerRef, confirmRef, teamErrorRef]
1382
+ .map(ref => ref.current?.offsetHeight ?? 0)
1383
+ .filter(height => height > 0)
1384
+ const fixed = heights.reduce((sum, height) => sum + height, 0)
1385
+ const fixedCount = heights.length
1386
+ setMetrics(previous => (previous.rowH === rowH && previous.headH === headH
1387
+ && previous.fixed === fixed && previous.fixedCount === fixedCount
1388
+ ? previous
1389
+ : { ...previous, rowH, headH, fixed, fixedCount }))
1390
+ })
1391
+
1392
+ // ---- placement: above the trigger, kept inside the viewport
1393
+ useLayoutEffect(() => {
1394
+ if (!open) {
1395
+ setMetrics(previous => (previous.box === null ? previous : { ...previous, box: null }))
1396
+ return undefined
1397
+ }
1398
+ const place = () => {
1399
+ const trigger = triggerRef.current
1400
+ const panel = panelRef.current
1401
+ if (trigger === null || panel === null) return
1402
+ const rect = trigger.getBoundingClientRect()
1403
+ const vw = window.innerWidth
1404
+ const vh = window.innerHeight
1405
+ const width = Math.max(280, Math.min(560, vw - 24))
1406
+ const height = panel.offsetHeight
1407
+ const left = Math.max(12, Math.min(rect.right - width, vw - 12 - width))
1408
+ let top = rect.top - 8 - height
1409
+ if (top < 12) top = Math.min(rect.bottom + 8, Math.max(12, vh - 12 - height))
1410
+ setMetrics(previous => {
1411
+ const box = previous.box
1412
+ if (box !== null && Math.abs(box.left - left) < 0.5
1413
+ && Math.abs(box.top - top) < 0.5 && Math.abs(box.width - width) < 0.5) return previous
1414
+ return { ...previous, box: { left, top, width } }
1415
+ })
1416
+ }
1417
+ place()
1418
+ window.addEventListener('resize', place)
1419
+ window.addEventListener('scroll', place, true)
1420
+ return () => {
1421
+ window.removeEventListener('resize', place)
1422
+ window.removeEventListener('scroll', place, true)
1423
+ }
1424
+ })
1425
+
1426
+ // Re-plan once fonts settle and on panel resize (debounced).
1427
+ useEffect(() => {
1428
+ if (!open) return undefined
1429
+ let timer = 0
1430
+ let cancelled = false
1431
+ const bump = () => {
1432
+ window.clearTimeout(timer)
1433
+ timer = window.setTimeout(() => { setReplan(value => value + 1) }, 80)
1434
+ }
1435
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(bump) : null
1436
+ if (observer !== null && panelRef.current !== null) observer.observe(panelRef.current)
1437
+ window.addEventListener('resize', bump)
1438
+ if (typeof document !== 'undefined' && document.fonts !== undefined) {
1439
+ document.fonts.ready.then(() => { if (!cancelled) setReplan(value => value + 1) })
1440
+ document.fonts.addEventListener('loadingdone', bump)
1441
+ }
1442
+ return () => {
1443
+ cancelled = true
1444
+ window.clearTimeout(timer)
1445
+ observer?.disconnect()
1446
+ window.removeEventListener('resize', bump)
1447
+ if (typeof document !== 'undefined' && document.fonts !== undefined) {
1448
+ document.fonts.removeEventListener('loadingdone', bump)
1449
+ }
1450
+ }
1451
+ }, [open])
1452
+
1453
+ // Dismissal by outside press.
1454
+ useEffect(() => {
1455
+ if (!open) return undefined
1456
+ const onPointerDown = event => {
1457
+ if (rootRef.current?.contains(event.target) === true) return
1458
+ if (panelRef.current?.contains(event.target) === true) return
1459
+ setOpen(false)
1460
+ }
1461
+ document.addEventListener('mousedown', onPointerDown)
1462
+ return () => { document.removeEventListener('mousedown', onPointerDown) }
1463
+ }, [open])
1464
+
1465
+ // Opening: reset transient state, refresh the directory, focus search.
1466
+ useEffect(() => {
1467
+ if (!open) return undefined
1468
+ setQuery('')
1469
+ setConfirming(false)
1470
+ setTeamFailure(null)
1471
+ load()
1472
+ const timer = window.setTimeout(() => { searchRef.current?.focus() }, 0)
1473
+ return () => { window.clearTimeout(timer) }
1474
+ }, [open, load])
1475
+
1476
+ const close = useCallback((restoreFocus = false) => {
1477
+ setOpen(false)
1478
+ setConfirming(false)
1479
+ if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
1480
+ }, [])
1481
+
1482
+ const submit = useCallback(selection => {
1483
+ setSelectionFocus(true)
1484
+ triggerRef.current?.focus()
1485
+ try {
1486
+ void Promise.resolve(select(selection)).catch(() => { /* surfaced on the store */ })
1487
+ } catch {
1488
+ /* a session that cannot select keeps its previous selection */
1489
+ }
1490
+ }, [select])
1491
+
1492
+ const chooseModel = useCallback((providerId, model) => {
1493
+ if (state.current?.provider === providerId && state.current.model === model.id) {
1494
+ close(true)
1495
+ return
1496
+ }
1497
+ submit({ provider: providerId, model: model.id })
1498
+ }, [close, state.current, submit])
1499
+
1500
+ const chooseEffort = useCallback(level => {
1501
+ if (state.current === null || effectiveEffort === level) return
1502
+ submit({
1503
+ provider: state.current.provider,
1504
+ model: state.current.model,
1505
+ reasoningEffort: level,
1506
+ })
1507
+ }, [effectiveEffort, state.current, submit])
1508
+
1509
+ const applyTeamMode = useCallback(enabled => {
1510
+ setTeamPending(true)
1511
+ setTeamFailure(null)
1512
+ void setTeamMode(enabled).then(result => {
1513
+ if (result !== undefined && result.ok === false) {
1514
+ const message = result.error?.code === 'session/writer-held'
1515
+ ? t('error.sessionInUse')
1516
+ : `${result.error?.code ?? 'error'}: ${result.error?.message ?? ''}`
1517
+ setTeamFailure(fill(t, 'team.failed', { message }))
1518
+ }
1519
+ }, error => {
1520
+ setTeamFailure(fill(t, 'team.failed', {
1521
+ message: error instanceof Error ? error.message : String(error),
1522
+ }))
1523
+ }).finally(() => { setTeamPending(false) })
1524
+ }, [setTeamMode, t])
1525
+
1526
+ const changeTeamMode = useCallback(enabled => {
1527
+ if (!enabled && modelSelection !== undefined && modelSelection.lastUsed !== null) {
1528
+ setConfirming(true)
1529
+ return
1530
+ }
1531
+ applyTeamMode(enabled)
1532
+ }, [applyTeamMode, modelSelection])
1533
+
1534
+ // ---- column planning (the whole point of the layout)
1535
+ // The grid is the only flexible child, so the space it may occupy is the
1536
+ // panel cap minus padding, every present fixed section, and the flex gaps
1537
+ // between them (one gap per fixed section, because the grid always sits
1538
+ // between them).
1539
+ const availableHeight = PANEL_MAX - PANEL_PAD * 2 - metrics.fixed
1540
+ - PANEL_GAP * metrics.fixedCount
1541
+ const units = useMemo(
1542
+ () => buildUnits(sections, metrics.rowH, metrics.headH),
1543
+ [sections, metrics.rowH, metrics.headH],
1544
+ )
1545
+ const plan = useMemo(
1546
+ () => planColumns(units, Math.max(0, availableHeight)),
1547
+ [units, availableHeight, replan],
1548
+ )
1549
+
1550
+ if (!available) return null
1551
+ if (addressedChild || listedChild) return null
1552
+
1553
+ const triggerModel = currentModel === null ? undefined : currentModel.model
1554
+ const waiting = currentModel === null && (state.status === 'loading' || state.status === 'idle')
1555
+ const modelLabel = waiting
1556
+ ? t('trigger.loading')
1557
+ : triggerModel?.name ?? t('trigger.fallback')
1558
+ const triggerText = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
1559
+ const triggerAria = waiting
1560
+ ? t('trigger.loading')
1561
+ : triggerModel === undefined
1562
+ ? t('trigger.fallback')
1563
+ : effortLabel === undefined
1564
+ ? fill(t, 'trigger.aria', { model: modelLabel })
1565
+ : fill(t, 'trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
1566
+
1567
+ const badgeOf = model => {
1568
+ const badge = model.badge
1569
+ if (badge === undefined || badge === null) return null
1570
+ const label = resolveText === undefined ? undefined : resolveText(badge.text)
1571
+ if (typeof label !== 'string' || label.trim() === '') return null
1572
+ return { label, tone: badge.tone ?? 'neutral' }
1573
+ }
1574
+
1575
+ /**
1576
+ * Split a model name into React children, lifting any "xN" multiplier
1577
+ * marker (rate/size tags like "x0.01", "x1.5", "X 2.2") into a smaller,
1578
+ * bold span so it reads as an aside while the full name and its order are
1579
+ * preserved. Matches an x (any case, or the multiplication sign) that
1580
+ * STARTS a token — preceded by start-of-string or a non-word character,
1581
+ * so it is not embedded in a word like "GenX5" — followed by optional
1582
+ * whitespace and a number (integer or decimal, optional leading +).
1583
+ * "Claude x3", "GPT x0.1", "Model X 2.2" all get the run; a plain
1584
+ * "Model X" (no number) or "GenX5" (x inside a word) do not.
1585
+ */
1586
+ const MULT_MARKER = /(?<![\p{L}\p{N}])[xX×]\s*[+]?\d+(?:\.\d+)?/gu
1587
+ const nameRuns = rawName => {
1588
+ const name = String(rawName ?? '')
1589
+ const out = []
1590
+ let cursor = 0
1591
+ for (const m of name.matchAll(MULT_MARKER)) {
1592
+ if (m.index > cursor) out.push(name.slice(cursor, m.index))
1593
+ out.push(h('span', { className: 'dshmf-rowNameMark' }, m[0]))
1594
+ cursor = m.index + m[0].length
1595
+ }
1596
+ if (cursor < name.length) out.push(name.slice(cursor))
1597
+ return out
1598
+ }
1599
+
1600
+ const renderRow = entry => {
1601
+ const { model, providerId, providerLabel, rowVariant } = entry
1602
+ const selected = state.current?.provider === providerId && state.current.model === model.id
1603
+ const pendingThis = state.pending?.provider === providerId && state.pending.model === model.id
1604
+ const badge = badgeOf(model)
1605
+ /**
1606
+ * Under family grouping the heading already says what the models ARE,
1607
+ * so the family name is not repeated in a row. The row's primary text
1608
+ * is the PROVIDER — the one thing a heading cannot tell you when a
1609
+ * family spans providers. Only when one provider offers several models
1610
+ * in this family does a short variant tail differentiate them. Under
1611
+ * provider grouping the heading IS the provider, so the primary text
1612
+ * stays the model name and no secondary tag is drawn.
1613
+ */
1614
+ const familyMode = grouping === 'family'
1615
+ const primary = familyMode && typeof providerLabel === 'string' && providerLabel !== ''
1616
+ ? providerLabel : String(model.name ?? '')
1617
+ const showVariant = familyMode && typeof rowVariant === 'string' && rowVariant !== ''
1618
+ const rowTitle = model.description === undefined
1619
+ ? `${model.name}\n${providerLabel}`
1620
+ : `${model.name}\n${model.description}\n${providerLabel}`
1621
+ // The model's last call ran out of balance and nothing has cleared it
1622
+ // yet: the row text is struck through until a later call for the same
1623
+ // model route returns anything else.
1624
+ const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
1625
+ return h('button', {
1626
+ key: `${providerId}/${model.id}`,
1627
+ type: 'button',
1628
+ className: 'dshmf-row',
1629
+ 'aria-pressed': selected,
1630
+ 'data-insufficient': insufficientRoute ? 'true' : undefined,
1631
+ title: rowTitle,
1632
+ disabled: busy,
1633
+ onClick: () => { chooseModel(providerId, model) },
1634
+ },
1635
+ // `nameRuns` (xN multiplier markers) is applied to whatever the
1636
+ // primary text is: the model name under provider grouping, or the
1637
+ // provider name under family grouping.
1638
+ h('span', { className: 'dshmf-rowName' }, ...nameRuns(primary)),
1639
+ showVariant ? h('span', {
1640
+ className: 'dshmf-rowProvider',
1641
+ title: rowVariant,
1642
+ }, rowVariant) : null,
1643
+ badge === null ? null : h('span', {
1644
+ className: 'dshmf-badge',
1645
+ 'data-tone': badge.tone,
1646
+ }, badge.label),
1647
+ pendingThis ? h('span', { className: 'dshmf-rowTail' }, h('span', { className: 'dshmf-spin' })) : null,
1648
+ )
1649
+ }
1650
+
1651
+ const showEmptyState = sections.length === 0 && !busy
1652
+ const emptyKey = state.status === 'loading' && totalModels === 0
1653
+ ? 'status.loading'
1654
+ : totalModels === 0 ? 'empty.models' : 'empty.search'
1655
+
1656
+ const renderBlock = block => (block.kind === 'title'
1657
+ ? h('div', {
1658
+ className: 'dshmf-groupTitle',
1659
+ key: block.key,
1660
+ title: block.label,
1661
+ }, block.label)
1662
+ : renderRow(block.entry))
1663
+
1664
+ const renderColumn = (column, columnIndex) => h('div', {
1665
+ className: 'dshmf-col' + (plan.count > 1 && columnIndex < plan.count - 1 ? ' dshmf-col--divided' : ''),
1666
+ key: `col-${columnIndex}`,
1667
+ }, column.map(unit => unit.blocks.map(renderBlock)))
1668
+
1669
+ const renderFailure = failure => h('div', {
1670
+ className: 'dshmf-failure',
1671
+ key: `failure-${failure.id}`,
1672
+ role: 'status',
1673
+ }, fill(t, 'failure.group', {
1674
+ name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name,
1675
+ message: failure.message,
1676
+ }))
1677
+
1678
+ const onPanelKeyDown = event => {
1679
+ if (event.key !== 'Escape') return
1680
+ event.preventDefault()
1681
+ if (confirming) {
1682
+ setConfirming(false)
1683
+ return
1684
+ }
1685
+ close(true)
1686
+ }
1687
+
1688
+ // Radio-group keyboard: arrows move the exclusive selection to the
1689
+ // neighbouring enabled level, so the whole group is reachable by keys.
1690
+ const onEffortKeyDown = event => {
1691
+ if (event.key !== 'ArrowRight' && event.key !== 'ArrowDown'
1692
+ && event.key !== 'ArrowLeft' && event.key !== 'ArrowUp') return
1693
+ const pills = reasonRef.current
1694
+ ? [...reasonRef.current.querySelectorAll('button.dshmf-pill:not(:disabled)')]
1695
+ : []
1696
+ if (pills.length === 0) return
1697
+ event.preventDefault()
1698
+ const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
1699
+ const at = event.target instanceof Element ? pills.indexOf(event.target) : -1
1700
+ const next = at === -1
1701
+ ? pills[forward ? 0 : pills.length - 1]
1702
+ : pills[(at + (forward ? 1 : -1) + pills.length) % pills.length]
1703
+ next?.focus()
1704
+ next?.click()
1705
+ }
1706
+
1707
+ return h('div', {
1708
+ className: 'dshmf-root',
1709
+ ref: rootRef,
1710
+ onMouseDown: event => {
1711
+ // WebKit blurs a focused row before click unless mousedown keeps focus.
1712
+ if (event.target instanceof Element && event.target.closest('button') !== null) event.preventDefault()
1713
+ },
1714
+ },
1715
+ h('button', {
1716
+ ref: triggerRef,
1717
+ type: 'button',
1718
+ className: 'dshmf-trigger',
1719
+ 'aria-label': triggerAria,
1720
+ 'aria-haspopup': 'dialog',
1721
+ 'aria-expanded': open,
1722
+ 'aria-controls': open ? `${id}-panel` : undefined,
1723
+ title: triggerText,
1724
+ 'aria-busy': busy,
1725
+ 'data-selection-focus': selectionFocus ? '' : undefined,
1726
+ disabled: locked,
1727
+ onBlur: () => { setSelectionFocus(false) },
1728
+ onClick: () => {
1729
+ if (open) {
1730
+ close(true)
1731
+ return
1732
+ }
1733
+ setSelectionFocus(false)
1734
+ triggerRef.current?.focus()
1735
+ setOpen(true)
1736
+ },
1737
+ },
1738
+ svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
1739
+ h('span', { className: 'dshmf-triggerText' }, modelLabel),
1740
+ effortLabel === undefined ? null : h('span', { className: 'dshmf-triggerEffort' }, effortLabel),
1741
+ busy
1742
+ ? h('span', { className: 'dshmf-spin' })
1743
+ : svg(PATH_CHEVRON, 12, { className: `dshmf-chevron${open ? ' dshmf-chevronOpen' : ''}` }),
1744
+ ),
1745
+
1746
+ open ? ReactDOM.createPortal(
1747
+ h('div', {
1748
+ ref: panelRef,
1749
+ id: `${id}-panel`,
1750
+ className: 'dshmf-panel',
1751
+ style: {
1752
+ left: `${metrics.box?.left ?? 12}px`,
1753
+ top: `${metrics.box?.top ?? 12}px`,
1754
+ width: `${metrics.box?.width ?? 320}px`,
1755
+ },
1756
+ role: 'dialog',
1757
+ 'aria-label': t('panel.aria'),
1758
+ onKeyDown: onPanelKeyDown,
1759
+ },
1760
+ // ---- fixed: search + grouping segment
1761
+ h('div', { className: 'dshmf-head', ref: headRef },
1762
+ h('div', { className: 'dshmf-search' },
1763
+ h('input', {
1764
+ ref: searchRef,
1765
+ className: 'dshmf-input',
1766
+ type: 'text',
1767
+ role: 'searchbox',
1768
+ 'aria-label': t('search.placeholder'),
1769
+ placeholder: t('search.placeholder'),
1770
+ value: query,
1771
+ readOnly: busy,
1772
+ onChange: event => { setQuery(event.target.value) },
1773
+ }),
1774
+ state.status === 'loading'
1775
+ ? h('span', { className: 'dshmf-spin', title: t('status.loading') })
1776
+ : null,
1777
+ query === '' ? null : h('button', {
1778
+ type: 'button',
1779
+ className: 'dshmf-clear',
1780
+ 'aria-label': t('search.clear'),
1781
+ title: t('search.clear'),
1782
+ onClick: () => {
1783
+ setQuery('')
1784
+ searchRef.current?.focus()
1785
+ },
1786
+ }, svg(PATH_CLOSE, 10)),
1787
+ ),
1788
+ h('div', { className: 'dshmf-seg', role: 'group', 'aria-label': t('group.aria') },
1789
+ h('button', {
1790
+ type: 'button',
1791
+ className: 'dshmf-segBtn',
1792
+ 'aria-pressed': grouping === 'provider',
1793
+ title: t('group.provider'),
1794
+ onClick: () => { setGrouping('provider'); writeGrouping('provider') },
1795
+ }, t('group.provider')),
1796
+ h('button', {
1797
+ type: 'button',
1798
+ className: 'dshmf-segBtn',
1799
+ 'aria-pressed': grouping === 'family',
1800
+ title: t('group.family'),
1801
+ onClick: () => { setGrouping('family'); writeGrouping('family') },
1802
+ }, t('group.family')),
1803
+ ),
1804
+ ),
1805
+
1806
+ // ---- fixed: failure strip above the grid, so it never competes
1807
+ // with model rows for the scroll area
1808
+ state.error === null ? null : h('div', {
1809
+ className: 'dshmf-error',
1810
+ ref: errorRef,
1811
+ role: 'status',
1812
+ }, fill(t, 'error.action', {
1813
+ message: state.error.includes('session/writer-held')
1814
+ ? t('error.sessionInUse')
1815
+ : state.error,
1816
+ })),
1817
+
1818
+ // ---- the only scrolling region
1819
+ h('div', {
1820
+ className: 'dshmf-grid',
1821
+ style: {
1822
+ gridTemplateColumns: `repeat(${plan.count}, minmax(0, 1fr))`,
1823
+ columnGap: `${COL_GAP}px`,
1824
+ },
1825
+ },
1826
+ showEmptyState
1827
+ ? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
1828
+ : plan.columns.map(renderColumn),
1829
+ state.failures.length === 0 ? null : state.failures.map(renderFailure),
1830
+ ),
1831
+
1832
+ // ---- fixed: reasoning levels of the current model, rendered as
1833
+ // one labelled group of mutually exclusive choices
1834
+ // ---- fixed, bottom: the agent teamwork switch. `teamAvailable`
1835
+ // is evaluated per render because the remote scope may bind late.
1836
+ // Both live in ONE footer flex container: they share a line when
1837
+ // they fit with the 30px gap, otherwise each takes its own line.
1838
+ (effortChoices.length === 0 && (teamMode === undefined || !teamAvailable()))
1839
+ ? null
1840
+ : h('div', { className: 'dshmf-footer', ref: footerRef },
1841
+ effortChoices.length === 0 ? null : h('div', {
1842
+ className: 'dshmf-reason',
1843
+ ref: reasonRef,
1844
+ role: 'radiogroup',
1845
+ 'aria-labelledby': `${id}-effort-label`,
1846
+ onKeyDown: onEffortKeyDown,
1847
+ },
1848
+ h('span', {
1849
+ className: 'dshmf-reasonLabel',
1850
+ id: `${id}-effort-label`,
1851
+ title: t('effort.hint'),
1852
+ }, svg(PATH_SPARK, 12), t('effort.label')),
1853
+ h('div', { className: 'dshmf-pills' }, effortChoices.map(level => {
1854
+ const selected = effectiveEffort === level.id
1855
+ const pendingThis = state.pending !== null
1856
+ && state.pending.provider === state.current?.provider
1857
+ && state.pending.model === state.current.model
1858
+ && state.pending.reasoningEffort === level.id
1859
+ return h('button', {
1860
+ key: level.id,
1861
+ type: 'button',
1862
+ role: 'radio',
1863
+ className: 'dshmf-pill',
1864
+ 'aria-checked': selected,
1865
+ title: level.description ?? level.name,
1866
+ disabled: busy || state.current === null,
1867
+ onClick: () => { chooseEffort(level.id) },
1868
+ }, pendingThis
1869
+ ? h('span', { className: 'dshmf-spin' })
1870
+ : h('span', { className: 'dshmf-pillName' }, level.name))
1871
+ })),
1872
+ ),
1873
+ teamMode === undefined || !teamAvailable()
1874
+ ? null
1875
+ : h('div', { className: 'dshmf-team', ref: teamRef },
1876
+ h('span', { className: 'dshmf-teamLabel' },
1877
+ h('span', { className: 'dshmf-teamName' }, t('team.label')),
1878
+ h('span', {
1879
+ className: 'dshmf-info',
1880
+ title: t('team.description'),
1881
+ 'aria-hidden': 'true',
1882
+ }, svg(PATH_INFO, 12)),
1883
+ ),
1884
+ h('button', {
1885
+ type: 'button',
1886
+ role: 'switch',
1887
+ className: 'dshmf-switch',
1888
+ 'aria-checked': teamMode.enabled,
1889
+ 'aria-label': t('team.label'),
1890
+ title: t('team.description'),
1891
+ disabled: locked || busy || teamPending || modelSelection === undefined,
1892
+ onClick: () => { changeTeamMode(!teamMode.enabled) },
1893
+ }, h('span', { className: 'dshmf-thumb' })),
1894
+ ),
1895
+ ),
1896
+
1897
+ // ---- fixed: teamwork disable confirmation
1898
+ confirming ? h('div', {
1899
+ className: 'dshmf-confirm',
1900
+ ref: confirmRef,
1901
+ role: 'alertdialog',
1902
+ 'aria-label': t('team.disable.title'),
1903
+ },
1904
+ h('span', { className: 'dshmf-confirmTitle' }, t('team.disable.title')),
1905
+ h('span', { className: 'dshmf-confirmBody' },
1906
+ `${t('team.disable.description')} ${t('team.disable.warning')}`),
1907
+ h('div', { className: 'dshmf-confirmActions' },
1908
+ h('button', {
1909
+ type: 'button',
1910
+ className: 'dshmf-confirmBtn',
1911
+ onClick: () => { setConfirming(false) },
1912
+ }, t('team.disable.cancel')),
1913
+ h('button', {
1914
+ type: 'button',
1915
+ className: 'dshmf-confirmBtn',
1916
+ 'data-primary': 'true',
1917
+ onClick: () => {
1918
+ setConfirming(false)
1919
+ applyTeamMode(false)
1920
+ },
1921
+ }, t('team.disable.confirm')),
1922
+ ),
1923
+ ) : null,
1924
+
1925
+ // ---- fixed: teamwork failure strip
1926
+ teamFailure === null ? null : h('div', {
1927
+ className: 'dshmf-error',
1928
+ ref: teamErrorRef,
1929
+ role: 'status',
1930
+ }, teamFailure),
1931
+
1932
+ // ---- measurement probe (never painted). It mirrors a family-
1933
+ // grouped row (provider primary + variant tag), the widest row
1934
+ // shape, so the measured row height covers every mode. It carries
1935
+ // no trailing tail: the selected row is a filled band now and the
1936
+ // spinner is only drawn for a pending selection.
1937
+ h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
1938
+ h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' }, 'X'),
1939
+ h('div', { ref: probeRowRef, className: 'dshmf-row' },
1940
+ h('span', { className: 'dshmf-rowName' }, 'X'),
1941
+ h('span', { className: 'dshmf-rowProvider' }, 'X'),
1942
+ ),
1943
+ ),
1944
+ ),
1945
+ document.body,
1946
+ ) : null,
1947
+ )
1948
+ }
1949
+
1950
+ // ---------------------------------------------------------------------
1951
+ // Seat boundary
1952
+ // ---------------------------------------------------------------------
1953
+ /**
1954
+ * Keep the composer seat alive when its content fails.
1955
+ *
1956
+ * A shadowing slot entry that throws during render abdicates, and the cell
1957
+ * falls to the next registration — the native picker reappears and this
1958
+ * plugin looks like it never installed. Catching here turns any failure into
1959
+ * a small visible chip, so the seat never disappears and the reason stays in
1960
+ * the console.
1961
+ */
1962
+ class SeatBoundary extends React.Component {
1963
+ constructor(props) {
1964
+ super(props)
1965
+ this.state = { failure: null }
1966
+ }
1967
+
1968
+ static getDerivedStateFromError(error) {
1969
+ return { failure: error instanceof Error ? error : new Error(String(error)) }
1970
+ }
1971
+
1972
+ componentDidCatch(error, info) {
1973
+ console.error('dsh-model-flatmap: the model seat failed and fell back to a minimal control', error, info)
1974
+ }
1975
+
1976
+ render() {
1977
+ const { failure } = this.state
1978
+ if (failure === null) return this.props.children
1979
+ const label = this.props.label ?? 'Model'
1980
+ // The reason is shown, not just logged: without browser console access a
1981
+ // silent fallback is indistinguishable from "the plugin never installed".
1982
+ const reason = String(failure && failure.message ? failure.message : failure)
1983
+ return h('div', { className: 'dshmf-root' },
1984
+ h('button', {
1985
+ type: 'button',
1986
+ className: 'dshmf-trigger',
1987
+ disabled: true,
1988
+ title: `${label}: ${reason}`,
1989
+ 'aria-label': `${label} — ${reason}`,
1990
+ },
1991
+ svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
1992
+ h('span', { className: 'dshmf-triggerText' }, `${label}: ${reason.slice(0, 90)}`),
1993
+ ),
1994
+ )
1995
+ }
1996
+ }
1997
+
1998
+ /** Labels whose derivation failure has already been reported. */
1999
+ const reportedDerivations = new Set()
2000
+
2001
+ /**
2002
+ * Run a pure derivation and degrade instead of throwing.
2003
+ * @param label - which derivation failed, for the console.
2004
+ * @param run - the derivation.
2005
+ * @param fallback - the value to use when it throws.
2006
+ * @returns the derived value, or the fallback.
2007
+ */
2008
+ function guarded(label, run, fallback) {
2009
+ try {
2010
+ return run()
2011
+ } catch (error) {
2012
+ if (!reportedDerivations.has(label)) {
2013
+ reportedDerivations.add(label)
2014
+ console.error(`dsh-model-flatmap: ${label} failed; the panel degrades`, error)
2015
+ }
2016
+ return fallback
2017
+ }
2018
+ }
2019
+
2020
+ /**
2021
+ * The registered seat: a boundary around the panel, so a content failure
2022
+ * cannot remove the entry from the slot.
2023
+ * @param props - the slot's standard props plus the injected face.
2024
+ */
2025
+ function Seat(props) {
2026
+ const translate = typeof props.t === 'function' ? props.t : key => key
2027
+ return h(SeatBoundary, { label: translate('trigger.fallback') },
2028
+ h(ModelFlatMap, props))
2029
+ }
2030
+
2031
+ // ---------------------------------------------------------------------
2032
+ // Plugin
2033
+ // ---------------------------------------------------------------------
2034
+ return {
2035
+ // `remote.agentTeams` is a SEPARATE client service (the gateway installs
2036
+ // each remote namespace as its own service). Reading it through a bare
2037
+ // `ctx.get('remote')` property lookup is refused by the guarded client
2038
+ // context unless both names are declared here, and a swallowed refusal
2039
+ // used to leave `teamAvailable` permanently false — i.e. the host's
2040
+ // teamwork switch never rendered at all.
2041
+ inject: ['slots', 'locale', 'remote', 'remote.agentTeams', 'sessions'],
2042
+ // Pure layout and grouping derivations plus the component itself,
2043
+ // exposed for `node --test` so grouping, filtering and the column
2044
+ // algorithm are pinned without a browser. The shell never reads this key.
2045
+ __test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, foldInsufficient, routeOfHeader, routeKeyOf },
2046
+ __component: ModelFlatMap,
2047
+ __seat: Seat,
2048
+ apply(ctx) {
2049
+ const read = name => {
2050
+ try {
2051
+ return typeof ctx.get === 'function' ? ctx.get(name) : undefined
2052
+ } catch {
2053
+ return undefined
2054
+ }
2055
+ }
2056
+
2057
+ // Dictionary registration must never be able to take the seat down: a
2058
+ // namespace left behind by a previous module generation throws on
2059
+ // re-registration, and losing the copy is far better than losing the
2060
+ // control.
2061
+ try {
2062
+ const locale = read('locale')
2063
+ if (locale !== undefined && typeof locale.register === 'function') {
2064
+ ctx.effect(() => locale.register(NS, dict), 'dsh-model-flatmap: dictionaries')
2065
+ }
2066
+ } catch (error) {
2067
+ console.error('dsh-model-flatmap: dictionaries were not registered', error)
2068
+ }
2069
+
2070
+ const resolveText = value => {
2071
+ try {
2072
+ const locale = read('locale')
2073
+ return locale === undefined ? fallbackText(value) : locale.resolveText(value)
2074
+ } catch {
2075
+ return fallbackText(value)
2076
+ }
2077
+ }
2078
+
2079
+ // Services are read at CALL time, never captured once: a client reload
2080
+ // replaces them, and a value captured during activation then throws
2081
+ // forever. `slots.inject` additionally waits for the declaration, so the
2082
+ // registration below runs exactly when the seat exists.
2083
+ const slots = read('slots')
2084
+ if (slots === undefined || typeof slots.inject !== 'function') return
2085
+
2086
+ // The teamwork switch is driven by the Host's own `remote.agentTeams`
2087
+ // namespace service. The gateway installs each remote namespace as its
2088
+ // OWN service, so it is read by full name through `ctx.get`, never as a
2089
+ // property of the `remote` service (a bare `ctx.remote` lookup is
2090
+ // refused by the guarded client context, and the old try/catch turned
2091
+ // that refusal into a silent `undefined` — which hid the switch
2092
+ // forever). `ctx.inject` is not among the client facade's verbs, so the
2093
+ // handle is resolved lazily on every use instead of once at activation.
2094
+ const readTeamService = () => {
2095
+ try {
2096
+ return typeof ctx.get === 'function' ? ctx.get('remote.agentTeams') : undefined
2097
+ } catch {
2098
+ return undefined
2099
+ }
2100
+ }
2101
+
2102
+ /**
2103
+ * Build the injected face for one Session.
2104
+ * @param sessionId - the Session this seat renders for.
2105
+ * @returns the face the seat component receives.
2106
+ */
2107
+ const buildFace = sessionId => {
2108
+ // Every capability is resolved per call, so a Session that binds late,
2109
+ // or a reloaded service, is picked up without tearing the seat down.
2110
+ const safeRead = name => {
2111
+ try {
2112
+ return read(name)
2113
+ } catch {
2114
+ return undefined
2115
+ }
2116
+ }
2117
+ const models = safeRead('modelDirectories')
2118
+ const sessions = safeRead('sessions')
2119
+
2120
+ const resolve = () => {
2121
+ try {
2122
+ // `subagentAddress` is the documented way to tell an addressed
2123
+ // child from an ordinary Session; without it, treat the Session as
2124
+ // selectable rather than blanking the composer.
2125
+ if (typeof sessions?.subagentAddress === 'function'
2126
+ && sessions.subagentAddress(sessionId) !== undefined) {
2127
+ return { addressed: true }
2128
+ }
2129
+ if (models === undefined || typeof models.directoryFor !== 'function') {
2130
+ return { addressed: false, pending: 'modelDirectories unavailable' }
2131
+ }
2132
+ const directory = models.directoryFor(sessionId)
2133
+ return { addressed: false, directory, store: directory.store }
2134
+ } catch (error) {
2135
+ return { addressed: false, pending: error instanceof Error ? error.message : String(error) }
2136
+ }
2137
+ }
2138
+ const first = resolve()
2139
+
2140
+ // ---- insufficient-balance marks, folded from the live event feed
2141
+ //
2142
+ // `sessions.binding(id).eventSource` is the Client's contiguous
2143
+ // durable event window (the same feed the Chat reads `turn/end` from).
2144
+ // The face keeps ONE immutable snapshot object and replaces it only
2145
+ // when a fold actually changes the marks, so `useSyncExternalStore`
2146
+ // re-renders the panel on a real transition and stays quiet on every
2147
+ // other event.
2148
+ const insufficientListeners = new Set()
2149
+ let insufficientMarks = new Map()
2150
+ let insufficientSnapshot = { marks: insufficientMarks }
2151
+ // The route of the latest request header: a `turn/end` belongs to the
2152
+ // model that request was dispatched to.
2153
+ let pendingRoute = null
2154
+ let stopInsufficient = null
2155
+
2156
+ /** Publish a new snapshot only when the mark set really changed. */
2157
+ const publishInsufficient = () => {
2158
+ insufficientSnapshot = { marks: insufficientMarks }
2159
+ for (const listener of insufficientListeners) listener()
2160
+ }
2161
+
2162
+ /** Read the durable entries out of the current window snapshot. */
2163
+ const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
2164
+
2165
+ /**
2166
+ * Fold one batch of durable entries into the marks. Live chunks carry
2167
+ * no `turn/end`, so they are skipped by type inside the fold.
2168
+ * @param entries - durable event entries from the feed.
2169
+ * @returns whether any mark changed.
2170
+ */
2171
+ const absorb = entries => {
2172
+ let changed = false
2173
+ for (const entry of entries) {
2174
+ const event = entry?.event
2175
+ if (event === null || event === undefined) continue
2176
+ const header = routeOfHeader(event)
2177
+ if (header !== null) {
2178
+ pendingRoute = header
2179
+ continue
2180
+ }
2181
+ if (event.type !== 'turn/end') continue
2182
+ if (foldInsufficient(insufficientMarks, event, pendingRoute)) changed = true
2183
+ }
2184
+ return changed
2185
+ }
2186
+
2187
+ /**
2188
+ * Attach the feed listener. A `replace` (initial load, history
2189
+ * replacement) refolds from scratch, because the window it describes
2190
+ * is the whole truth; an `append` folds only the new entries. A
2191
+ * `prepend` loads OLDER history at the front and cannot change a mark
2192
+ * (marks come from the tail), so it is ignored rather than refolded —
2193
+ * refolding would also re-walk history on every page-in.
2194
+ */
2195
+ const startInsufficientFeed = () => {
2196
+ if (stopInsufficient !== null) return
2197
+ let binding
2198
+ try {
2199
+ binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
2200
+ } catch {
2201
+ binding = undefined
2202
+ }
2203
+ const source = binding?.eventSource
2204
+ if (source === undefined || typeof source.subscribe !== 'function'
2205
+ || typeof source.getSnapshot !== 'function') return
2206
+ /** Whether two mark maps hold the same entries. */
2207
+ const sameMarks = (left, right) => {
2208
+ if (left.size !== right.size) return false
2209
+ for (const [key, value] of left) {
2210
+ if (right.get(key) !== value) return false
2211
+ }
2212
+ return true
2213
+ }
2214
+ const applyWindow = (window, full) => {
2215
+ const change = window?.change
2216
+ if (full || change?.kind === 'replace') {
2217
+ const previous = insufficientMarks
2218
+ const next = new Map()
2219
+ insufficientMarks = next
2220
+ pendingRoute = null
2221
+ absorb(entriesOf(window))
2222
+ // Adopt the refolded map as the snapshot either way, so the
2223
+ // snapshot and the live state never diverge; only NOTIFY when
2224
+ // the marks really changed, so a history replacement that
2225
+ // re-derives the same marks does not repaint the panel.
2226
+ insufficientSnapshot = { marks: next }
2227
+ if (!sameMarks(previous, next)) publishInsufficient()
2228
+ return
2229
+ }
2230
+ if (change?.kind === 'append' && absorb(change.entries)) publishInsufficient()
2231
+ }
2232
+ try {
2233
+ applyWindow(source.getSnapshot(), true)
2234
+ } catch {
2235
+ /* an unreadable feed leaves the panel unmarked, never broken */
2236
+ }
2237
+ const listener = () => {
2238
+ try {
2239
+ applyWindow(source.getSnapshot(), false)
2240
+ } catch {
2241
+ /* a fold failure must not escape into the slot */
2242
+ }
2243
+ }
2244
+ try {
2245
+ const stop = source.subscribe(listener)
2246
+ stopInsufficient = typeof stop === 'function' ? stop : () => {}
2247
+ } catch {
2248
+ stopInsufficient = null
2249
+ }
2250
+ }
2251
+
2252
+ startInsufficientFeed()
2253
+
2254
+ /**
2255
+ * The seat's read face for the marks. `subscribe` and `getSnapshot`
2256
+ * are the `useSyncExternalStore` pair; the snapshot object is stable
2257
+ * between real transitions.
2258
+ */
2259
+ const insufficient = {
2260
+ subscribe: listener => {
2261
+ insufficientListeners.add(listener)
2262
+ // The binding may have materialized after the face was built.
2263
+ startInsufficientFeed()
2264
+ return () => { insufficientListeners.delete(listener) }
2265
+ },
2266
+ getSnapshot: () => insufficientSnapshot,
2267
+ /** Stop observing the feed; called when the seat's owner disposes. */
2268
+ dispose: () => {
2269
+ if (stopInsufficient !== null) stopInsufficient()
2270
+ stopInsufficient = null
2271
+ insufficientListeners.clear()
2272
+ },
2273
+ }
2274
+
2275
+ return {
2276
+ sessionId,
2277
+ resolveDirectory: resolve,
2278
+ safeRead,
2279
+ insufficient,
2280
+ // `available: false` only for an addressed subagent Session, which
2281
+ // the shipped seat also hides.
2282
+ available: first.addressed !== true,
2283
+ directory: first.store,
2284
+ load: () => {
2285
+ const current = resolve()
2286
+ if (current.directory !== undefined) {
2287
+ current.directory.load().catch(() => { /* surfaced on the store */ })
2288
+ }
2289
+ },
2290
+ select: selection => {
2291
+ const current = resolve()
2292
+ return current.directory === undefined
2293
+ ? Promise.resolve(undefined)
2294
+ : current.directory.select(selection)
2295
+ },
2296
+ resolveText,
2297
+ // Lazy, not snapshotted: the namespace service may still be
2298
+ // mounting when this face is built, and the seat is registered
2299
+ // once. Re-reading on each use keeps the switch from being frozen
2300
+ // out by an early `undefined`.
2301
+ teamAvailable: () => readTeamService() !== undefined,
2302
+ setTeamMode: enabled => {
2303
+ const service = readTeamService()
2304
+ return service === undefined
2305
+ ? Promise.resolve(undefined)
2306
+ : service.setEnabled(sessionId, enabled)
2307
+ },
2308
+ }
2309
+ }
2310
+
2311
+ /**
2312
+ * Call the face builder and never let a throw escape into slot assembly,
2313
+ * where it would abdicate the entry and hand the cell back to the native
2314
+ * picker. A failed face still renders: the seat reports that it is
2315
+ * unavailable instead of disappearing.
2316
+ * @param sessionId - the Session this seat renders for.
2317
+ * @param build - the face builder.
2318
+ * @returns an injected face, degraded when building failed.
2319
+ */
2320
+ const safeFace = (sessionId, build) => {
2321
+ try {
2322
+ return build(sessionId)
2323
+ } catch (error) {
2324
+ console.error('dsh-model-flatmap: the seat face failed to build', error)
2325
+ return {
2326
+ sessionId,
2327
+ available: true,
2328
+ directory: undefined,
2329
+ resolveDirectory: () => ({ addressed: false, pending: 'seat unavailable' }),
2330
+ load: () => {},
2331
+ select: () => Promise.resolve(undefined),
2332
+ resolveText,
2333
+ teamAvailable: () => false,
2334
+ setTeamMode: () => Promise.resolve(undefined),
2335
+ // A face that failed to build still answers the mark face, so the
2336
+ // component's hook shape never changes and the rows simply stay
2337
+ // unstruck. It shares the module-level empty snapshot so the
2338
+ // identity stays stable across renders.
2339
+ insufficient: {
2340
+ subscribe: () => () => {},
2341
+ getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
2342
+ dispose: () => {},
2343
+ },
2344
+ }
2345
+ }
2346
+ }
2347
+
2348
+ /**
2349
+ * Register the seat, escalating priority when a stale generation still
2350
+ * holds the previous entry.
2351
+ */
2352
+ const registerSeat = () => {
2353
+ let lastError
2354
+ for (const priority of [-1, -2, -3, -4, -5]) {
2355
+ try {
2356
+ return slots.register({
2357
+ name: 'conversation.input.model',
2358
+ priority,
2359
+ locale: NS,
2360
+ inject: sessionId => safeFace(sessionId, buildFace),
2361
+ }, Seat)
2362
+ } catch (error) {
2363
+ lastError = error
2364
+ }
2365
+ }
2366
+ console.error('dsh-model-flatmap: the model seat was not registered', lastError)
2367
+ return () => {}
2368
+ }
2369
+
2370
+ try {
2371
+ slots.inject('conversation.input.model', registerSeat)
2372
+ } catch (error) {
2373
+ console.error('dsh-model-flatmap: slots.inject failed for the model seat', error)
2374
+ }
2375
+ },
2376
+ }
2377
+ },
2378
+ })