dsh-model-flatmap 0.0.0-stage → 1.0.1

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