@nimoxie/dsh-model-organizer 0.3.4

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/lib/client.js ADDED
@@ -0,0 +1,1836 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-model-organizer",
3
+ factory: (require) => {
4
+ const module = { exports: {} };
5
+ const exports = module.exports;
6
+
7
+ const React = require("react");
8
+ const ReactDOM = require("react-dom");
9
+ const h = React.createElement;
10
+ const useState = React.useState;
11
+ const useEffect = React.useEffect;
12
+ const useMemo = React.useMemo;
13
+ const useRef = React.useRef;
14
+ const useLayoutEffect = React.useLayoutEffect;
15
+ const useSyncExternalStore = React.useSyncExternalStore;
16
+
17
+ let P = null;
18
+ try { P = require("@deepseek-ai/dsh-client-ui-primitives"); } catch (e) { P = null; }
19
+ /* React.forwardRef components are OBJECTS (with a .render function), not
20
+ functions — a typeof gate on them silently fails and every consumer
21
+ falls back. Accept both shapes. */
22
+ function isComponent(x) {
23
+ return typeof x === "function" || (x !== null && typeof x === "object" && typeof x.render === "function");
24
+ }
25
+ /* The shipped menus draw their surface (translucent fill + backdrop blur +
26
+ radius) through this primitive; the `_menu` class only adds geometry.
27
+ Rendering our menu through it keeps the frost identical to the shipped
28
+ look — inlining the menu token alone paints a translucent fill with NO
29
+ blur, which reads as "see-through" instead of "frosted". */
30
+ const MENU_SURFACE = P !== null && isComponent(P.MenuSurface) ? P.MenuSurface : null;
31
+ /* Shared model-search ranking — the same primitive the shipped menu uses. */
32
+ const RANK_BY_NAME = P !== null && typeof P.rankByName === "function" ? P.rankByName : null;
33
+
34
+ const NS = "model-organizer";
35
+ const PI_AI_NS = "llm-pi-ai";
36
+
37
+ /* ------------------------------ dictionaries ------------------------------ */
38
+ const zh = {
39
+ "composer.title": "选择模型",
40
+ "composer.menuModel": "模型",
41
+ "composer.searchPlaceholder": "搜索模型…",
42
+ "composer.searchClear": "清除搜索",
43
+ "composer.searchEmpty": "没有匹配的模型。",
44
+ "composer.selectFailed": "选择失败:{msg}",
45
+ "composer.none": "未选择模型",
46
+ "composer.empty": "暂无可用模型",
47
+ "composer.locked": "当前不可切换模型",
48
+ "composer.count": "个模型",
49
+ "composer.aria": "选择模型,当前 {model}",
50
+ "composer.ariaEffort": "选择模型,当前 {model},推理等级 {effort}",
51
+ "composer.effort": "推理等级",
52
+ "composer.effortDefault": "Default",
53
+ "composer.effortEmpty": "当前模型未提供推理等级。",
54
+ "composer.effortBack": "模型",
55
+ "composer.refreshing": "正在刷新模型列表…",
56
+ "order.title": "模型顺序",
57
+ "order.hint": "拖动 ⠿ 调整,松手即保存",
58
+ "order.saving": "保存中…",
59
+ "order.saved": "已保存 ✓",
60
+ "order.unsaved": "有未保存改动",
61
+ "order.readonly": "当前环境不可写入该设置",
62
+ "providers.title": "供应商与模型顺序",
63
+ "providers.hint": "点击收起/展开,长按拖拽调整",
64
+ "providers.expand": "展开顺序面板",
65
+ "providers.collapse": "收起顺序面板",
66
+ "providers.saved": "已保存 ✓",
67
+ };
68
+ const en = {
69
+ "composer.title": "Select model",
70
+ "composer.menuModel": "Model",
71
+ "composer.searchPlaceholder": "Search models…",
72
+ "composer.searchClear": "Clear search",
73
+ "composer.searchEmpty": "No matching models.",
74
+ "composer.selectFailed": "Selection failed: {msg}",
75
+ "composer.none": "No model",
76
+ "composer.empty": "No models available",
77
+ "composer.locked": "Model switching unavailable",
78
+ "composer.count": "models",
79
+ "composer.aria": "Select model, currently {model}",
80
+ "composer.ariaEffort": "Select model, currently {model}, reasoning effort {effort}",
81
+ "composer.effort": "Effort",
82
+ "composer.effortDefault": "Default",
83
+ "composer.effortEmpty": "This model provides no reasoning effort levels.",
84
+ "composer.effortBack": "Model",
85
+ "composer.refreshing": "Refreshing model list…",
86
+ "order.title": "Model order",
87
+ "order.hint": "Drag ⠿ to reorder, then save",
88
+ "order.saving": "Saving…",
89
+ "order.saved": "Saved ✓",
90
+ "order.unsaved": "Unsaved changes",
91
+ "order.readonly": "This settings document is read-only here",
92
+ "providers.title": "Provider and model order",
93
+ "providers.hint": "Click to collapse/expand, press and drag to reorder",
94
+ "providers.expand": "Expand the order panel",
95
+ "providers.collapse": "Collapse the order panel",
96
+ "providers.saved": "Saved ✓",
97
+ };
98
+
99
+ /* --------------------------- official CSS classes -------------------------- */
100
+ /* The shipped client-ui-model-selection bundle injects its CSS-module sheet and
101
+ STILL loads (this plugin only shadows its seat), so reusing its class names
102
+ reproduces the shipped look exactly. The hash is resolved from the live
103
+ stylesheet so a dsh upgrade cannot silently drop the styling. */
104
+ const CLASS_SUFFIXES = ["root", "trigger", "triggerLabel", "triggerEffort", "triggerIcon", "chevron", "chevronOpen",
105
+ "menu", "status", "empty", "error", "warning", "retry", "groups", "group", "groupTitle",
106
+ "option", "selected", "optionCopy", "modelName", "check", "cell", "cellLabel", "cellValue", "cellChevron",
107
+ "searchRow", "search", "searchWithQuery", "searchClear"];
108
+
109
+ /* Resolution is cached against the live sheet count so a late-injected sheet is
110
+ still picked up, and a resolved prefix is VALIDATED against a second class
111
+ only this module owns — an unrelated module shipping a `_trigger` rule must
112
+ not win (the updater module does). */
113
+ /* Bump on every deploy. The host serves the plugin bundle as
114
+ `immutable, max-age=31536000` under a URL that does not change when a plugin's
115
+ code changes, so a browser can keep running an old build until a hard reload.
116
+ Kept OFF the UI (it is not user-facing): read it as
117
+ `window.__dshModelOrganizerBuild` when a stale build is suspected. */
118
+ const BUILD = "b29";
119
+
120
+ let officialClasses = null;
121
+ let officialClassesSheets = -1;
122
+ let officialClassesValidated = false;
123
+
124
+ function sheetRules() {
125
+ const out = [];
126
+ let sheets;
127
+ try { sheets = document.styleSheets; } catch (e) { return out; }
128
+ for (let i = 0; i < sheets.length; i++) {
129
+ let rules = null;
130
+ try { rules = sheets[i].cssRules; } catch (e) { continue; }
131
+ if (rules === null) continue;
132
+ for (let j = 0; j < rules.length; j++) {
133
+ const sel = rules[j].selectorText;
134
+ if (typeof sel === "string") out.push(sel);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function prefixIsValid(prefix, selectors) {
141
+ const marker = "." + prefix + "_optionCopy";
142
+ for (let i = 0; i < selectors.length; i++) if (selectors[i].indexOf(marker) !== -1) return true;
143
+ return false;
144
+ }
145
+
146
+ function resolveOfficialClasses() {
147
+ let sheetCount = 0;
148
+ try { sheetCount = document.styleSheets.length; } catch (e) { sheetCount = 0; }
149
+ if (officialClassesValidated && officialClassesSheets === sheetCount) return officialClasses;
150
+ /* Anchored on classes only the model selector owns — a bare "_trigger" probe
151
+ also matches unrelated modules (the updater module ships one too). */
152
+ const ANCHORS = ["_triggerEffort", "_optionCopy", "_chevronOpen", "_cellValue"];
153
+ const selectors = sheetRules();
154
+ let prefix = null;
155
+ for (let a = 0; a < ANCHORS.length && prefix === null; a++) {
156
+ for (let i = 0; i < selectors.length && prefix === null; i++) {
157
+ const at = selectors[i].indexOf(ANCHORS[a]);
158
+ if (at === -1) continue;
159
+ const found = /[.]([A-Za-z0-9_-]+)$/.exec(selectors[i].slice(0, at));
160
+ if (found === null) continue;
161
+ if (!prefixIsValid(found[1], selectors)) continue;
162
+ prefix = found[1];
163
+ }
164
+ }
165
+ officialClassesSheets = sheetCount;
166
+ officialClassesValidated = true;
167
+ if (prefix === null) { officialClasses = null; return null; }
168
+ const out = {};
169
+ for (let i = 0; i < CLASS_SUFFIXES.length; i++) out[CLASS_SUFFIXES[i]] = prefix + "_" + CLASS_SUFFIXES[i];
170
+ /* The search row ships only in DSH 0.2.0-rc.2+; flag its presence so the
171
+ composer renders the search box only where the official sheet can style
172
+ it (rc.1's sheet has no search rules and would leave it unstyled). */
173
+ const searchMarker = "." + prefix + "_searchRow";
174
+ for (let i = 0; i < selectors.length; i++) {
175
+ if (selectors[i].indexOf(searchMarker) !== -1) { out.hasSearchRow = true; break; }
176
+ }
177
+ officialClasses = out;
178
+ return out;
179
+ }
180
+
181
+ /* The shipped sheet styles a static group heading; the collapsible variant needs
182
+ a hover affordance and a chevron that flips. Injected once per document. */
183
+ const OWN_STYLE_ID = "dsh-model-organizer-style";
184
+ function ensureOwnStyles(C) {
185
+ try {
186
+ let style = document.getElementById(OWN_STYLE_ID);
187
+ if (style === null) {
188
+ style = document.createElement("style");
189
+ style.id = OWN_STYLE_ID;
190
+ document.head.appendChild(style);
191
+ }
192
+ const rules = [
193
+ ".dsh-mo-head{transition:background .12s ease;}",
194
+ ".dsh-mo-head:hover{background:var(--dsw-alias-interactive-bg-hover);}",
195
+ /* A drag leaves the source row FOCUSED in Chromium, and both the shipped
196
+ \`option\` and this sheet paint a :focus-visible background — that is the grey
197
+ block that used to stay behind after a drag. That suppression must stay
198
+ scoped to the settings drag lists (\.dsh-mo-list): the composer menu rows
199
+ take keyboard focus now, and the walk is invisible without the paint. */
200
+ ".dsh-mo-head:focus-visible{outline:none;background:var(--dsw-alias-interactive-bg-hover);}",
201
+ ".dsh-mo-list .dsh-mo-head.dsh-mo-head:focus-visible,.dsh-mo-list .dsh-mo-model.dsh-mo-model:focus-visible,.dsh-mo-list .dsh-mo-row.dsh-mo-row:focus-visible{background:transparent;}",
202
+ ".dsh-mo-panelhead.dsh-mo-panelhead:focus-visible{background:var(--dsw-alias-interactive-bg-hover);}",
203
+ ".dsh-mo-chev{transition:transform .12s ease;}",
204
+ ".dsh-mo-head[aria-expanded='true'] .dsh-mo-chev{transform:rotate(90deg);}",
205
+ ".dsh-mo-row{border-width:1px;border-style:solid;border-color:transparent;}",
206
+ /* No row may paint a hover tint while a drag owns the pointer. */
207
+ ".dsh-mo-nohover.dsh-mo-nohover .dsh-mo-head:hover,.dsh-mo-nohover.dsh-mo-nohover .dsh-mo-row:hover,.dsh-mo-nohover.dsh-mo-nohover .dsh-mo-model:hover{background:transparent;}",
208
+
209
+ /* The row IS the handle now: no grip glyph, the cursor says "drag me".
210
+ Scoped to the settings panel's own list — the composer menu reuses the
211
+ shipped cell/option classes together with dsh-mo-head / dsh-mo-model for
212
+ rows that are NOT draggable, and those must keep the shipped cursor. */
213
+ ".dsh-mo-list .dsh-mo-head{cursor:grab;}",
214
+ ".dsh-mo-list .dsh-mo-model{cursor:grab;}",
215
+ ".dsh-mo-list .dsh-mo-row{cursor:grab;}",
216
+ ".dsh-mo-list .dsh-mo-head:active,.dsh-mo-list .dsh-mo-model:active{cursor:grabbing;}",
217
+ /* The panel header is a drag handle, not a button: keep the cursor, drop the
218
+ hover tint. Doubled selectors outrank the shipped `cell` :hover rule. */
219
+ ".dsh-mo-panelhead.dsh-mo-panelhead{cursor:grab;}",
220
+ ".dsh-mo-panelhead.dsh-mo-panelhead:hover{background:transparent;}",
221
+ ".dsh-mo-list{display:flex;flex-direction:column;gap:2px;margin-top:2px;}",
222
+ ".dsh-mo-head{touch-action:none;user-select:none;-webkit-user-select:none;}",
223
+ /* Borrowed from the sidebar rows: without -webkit-user-drag the browser treats
224
+ a press-and-move over the row's TEXT as a NATIVE TEXT DRAG (the "release to
225
+ search" ghost) instead of our reorder drag. It must NOT go on the row itself
226
+ — that disables the row's own draggable behaviour — so it goes on the row's
227
+ contents, leaving the row draggable. */
228
+ ".dsh-mo-list .dsh-mo-head,.dsh-mo-list .dsh-mo-model,.dsh-mo-list .dsh-mo-row{user-select:none;-webkit-user-select:none;}",
229
+ ".dsh-mo-list .dsh-mo-head *,.dsh-mo-list .dsh-mo-model *,.dsh-mo-list .dsh-mo-row *{-webkit-user-drag:none;user-select:none;-webkit-user-select:none;}",
230
+ ".dsh-mo-panelhead,.dsh-mo-panelhead *{-webkit-user-drag:none;user-select:none;-webkit-user-select:none;}",
231
+ ".dsh-mo-moving,.dsh-mo-moving:hover{cursor:grabbing;background:transparent;}",
232
+ /* (the header's own arrow button is gone — the row toggles on click) */
233
+
234
+ ".dsh-mo-model{padding-left:28px;position:relative;}",
235
+ ".dsh-mo-model::before{content:'';position:absolute;left:15px;top:50%;width:5px;height:5px;margin-top:-2.5px;border-radius:50%;background:var(--dsw-alias-label-tertiary);opacity:.5;}",
236
+ ".dsh-mo-model[aria-checked='true']::before{background:var(--dsw-alias-label-primary);opacity:.9;}"
237
+ ];
238
+ /* The shipped check alone is easy to miss: bold the selected row's name
239
+ wherever the official sheet is in use. Settings rows carry no
240
+ aria-checked, so the drag lists are untouched. */
241
+ if (C !== null) {
242
+ rules.push("." + C.option + "[aria-checked='true'] ." + C.modelName + "{font-weight:600}");
243
+ }
244
+ style.textContent = rules.join("");
245
+ } catch (e) {}
246
+ }
247
+
248
+ function cx() {
249
+ let out = "";
250
+ for (let i = 0; i < arguments.length; i++) {
251
+ const v = arguments[i];
252
+ if (typeof v === "string" && v.length > 0) out = out.length === 0 ? v : out + " " + v;
253
+ }
254
+ return out;
255
+ }
256
+
257
+ function icon(name, props) {
258
+ if (P === null || !isComponent(P[name])) return null;
259
+ return h(P[name], props ? Object.assign({}, props) : {});
260
+ }
261
+
262
+ /* --------------------------------- styles --------------------------------- */
263
+ const S = {
264
+ chip: {
265
+ display: "inline-flex", alignItems: "center", gap: 6, maxWidth: 260,
266
+ padding: "3px 8px", borderRadius: 8, cursor: "pointer", fontSize: 12, lineHeight: "18px",
267
+ border: "1px solid var(--dsw-alias-border-l1)",
268
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
269
+ color: "var(--dsw-alias-label-primary)"
270
+ },
271
+ chipDisabled: { opacity: 0.5, cursor: "not-allowed" },
272
+ chipLabel: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
273
+ chipEffort: { color: "var(--dsw-alias-label-caption)", flexShrink: 1000, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 },
274
+ caret: { fontSize: 9, color: "var(--dsw-alias-label-tertiary)" },
275
+ menuEmpty: { padding: "10px 8px", fontSize: 12, color: "var(--dsw-alias-label-tertiary)" },
276
+ /* fallbacks used only when the shipped stylesheet could not be located */
277
+ fallbackMenu: {
278
+ position: "fixed", zIndex: 1100, maxHeight: "min(360px, 100vh - 96px)", overflow: "hidden",
279
+ display: "flex", flexDirection: "column", padding: 4,
280
+ background: "var(--dsw-specific-menu, var(--dsw-alias-bg-module, #222))",
281
+ color: "var(--dsw-alias-label-primary)", borderRadius: 20, boxShadow: "0 8px 28px rgba(0,0,0,.32)"
282
+ },
283
+ fallbackGroups: { overflowY: "auto", minHeight: 0, display: "flex", flexDirection: "column", gap: 4 },
284
+ fallbackGroup: { display: "flex", flexDirection: "column" },
285
+ fallbackGroupTitle: {
286
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6, width: "100%",
287
+ textAlign: "left", padding: "5px 8px 3px", border: "none", cursor: "pointer",
288
+ background: "transparent", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, fontWeight: 500
289
+ },
290
+ fallbackModel: {
291
+ display: "flex", alignItems: "center", gap: 8, width: "100%", minHeight: 38,
292
+ textAlign: "left", padding: "6px 8px", border: "none", borderRadius: 10, cursor: "pointer",
293
+ background: "transparent", color: "inherit"
294
+ },
295
+ fallbackCopy: { display: "flex", flexDirection: "column", flex: 1, minWidth: 0 },
296
+ fallbackCheck: { flex: "0 0 18px", display: "grid", placeItems: "center" },
297
+ /* the shipped groupTitle is a plain heading; the clickable variant only adds
298
+ layout + affordance so the visuals keep coming from the shipped sheet. */
299
+ groupChevron: { color: "var(--dsw-alias-label-tertiary)", flex: "none", transition: "transform .12s" },
300
+ measureStyle: { visibility: "hidden", left: 0, top: 0 },
301
+ groupCount: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)", flex: "none" },
302
+ failure: { display: "flex", flexDirection: "column", gap: 2, padding: "5px 8px", fontSize: 11, color: "var(--dsw-alias-state-warn-label, #d29922)" },
303
+ box: { marginTop: 8, padding: 8, border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 10, background: "var(--dsw-alias-bg-module-secondary, transparent)" },
304
+ providerGroup: { display: "flex", flexDirection: "column" },
305
+
306
+ /* Floating presentation: the panel sits in the corner of the Models page so
307
+ provider/API configuration and reordering are visible at the same time. */
308
+ floatPanel: {
309
+ position: "fixed", right: 20, bottom: 20, zIndex: 10000, width: 380, maxWidth: "calc(100vw - 40px)",
310
+ display: "flex", flexDirection: "column", padding: 4,
311
+ border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 14,
312
+ /* The theme's menu token is an OPAQUE colour (a color-mix), so using it as-is
313
+ makes this panel a solid box and the blur below has nothing to show. Mixing
314
+ it back toward transparent restores the frosted look in the theme's colour. */
315
+ background: "color-mix(in srgb, var(--dsw-specific-menu, #26262a) 50%, transparent)",
316
+ backdropFilter: "var(--dsw-menu-backdrop-filter, blur(28px) saturate(1.6))",
317
+ boxShadow: "var(--dsw-elevation-prominent, 0 10px 30px rgba(0,0,0,.34))",
318
+ color: "var(--dsw-alias-label-primary)"
319
+ },
320
+ /* The shipped `cell` sets min-width:100%; the head must stay inside the panel. */
321
+ panelHeadCell: { display: "flex", alignItems: "center", gap: 6, flex: "1 1 auto", minWidth: 0 },
322
+ floatBody: { display: "flex", flexDirection: "column", gap: 2, overflowY: "auto", maxHeight: "52vh", padding: "2px 4px 4px" },
323
+
324
+ boxHead: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6, flexWrap: "wrap" },
325
+ boxTitle: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
326
+ boxHint: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
327
+ list: { display: "flex", flexDirection: "column", gap: 2 },
328
+ row: {
329
+ display: "flex", alignItems: "center", gap: 8, padding: "5px 6px", borderRadius: 7,
330
+ fontSize: 12, color: "var(--dsw-alias-label-secondary)", outline: "none",
331
+ borderWidth: 1, borderStyle: "solid", borderColor: "transparent",
332
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.06))",
333
+ transition: "background .12s ease, border-color .12s ease, opacity .12s ease, transform .12s ease",
334
+ cursor: "grab"
335
+ },
336
+ /* Only border-color changes while dragging: keeping the border as longhands
337
+ everywhere stops React from expanding the shorthand and then dropping
338
+ border-color, which would fall back to currentColor and look like a
339
+ highlight that never clears. */
340
+ /* Only the drag affordances ride inline when the shipped classes are in use. */
341
+ rowDraggingOnly: {
342
+ opacity: 0.55, cursor: "grabbing", borderColor: "var(--dsw-alias-state-accent, #4f8cff)", transform: "scale(1.01)"
343
+ },
344
+ rowDragging: {
345
+ opacity: 0.55, cursor: "grabbing",
346
+ borderColor: "var(--dsw-alias-state-accent, #4f8cff)",
347
+ background: "var(--dsw-alias-interactive-bg, rgba(79,140,255,.14))",
348
+ transform: "scale(1.01)"
349
+ },
350
+ rowName: { flex: "1 1 auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
351
+ /* Right-aligned "N models" count on a provider row (fallback styling only). */
352
+ rowMeta: { flex: "none", fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
353
+ dirty: { fontSize: 11, color: "var(--dsw-alias-state-warn-label, #d29922)" },
354
+ ok: { fontSize: 11, color: "var(--dsw-alias-state-success, #3fb950)" }
355
+ };
356
+
357
+ /* --------------------------- drag reorder helper -------------------------- */
358
+ /* Live preview: the list re-renders in the would-be order while dragging, so
359
+ neighbouring rows visibly move out of the way before the drop lands. */
360
+ const MEASURE_STYLE = S.measureStyle;
361
+
362
+ function moved(ids, from, to) {
363
+ const next = ids.slice();
364
+ const item = next.splice(from, 1)[0];
365
+ next.splice(to, 0, item);
366
+ return next;
367
+ }
368
+
369
+ /* A transient status note. The timer is kept per instance so a second save cannot
370
+ be cut short by the first save's timer, and nothing fires after unmount. */
371
+ function useNote() {
372
+ const state = useState(null);
373
+ const timer = useRef(null);
374
+ useEffect(function () {
375
+ return function () {
376
+ if (timer.current !== null) clearTimeout(timer.current);
377
+ };
378
+ }, []);
379
+ const set = function (value, ttl) {
380
+ if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; }
381
+ state[1](value);
382
+ if (value !== null && typeof ttl === "number") {
383
+ timer.current = setTimeout(function () {
384
+ timer.current = null;
385
+ state[1](null);
386
+ }, ttl);
387
+ }
388
+ };
389
+ return [state[0], set];
390
+ }
391
+
392
+ function DragList(props) {
393
+ const ids = props.ids;
394
+ const render = props.render;
395
+ const onCommit = props.onCommit;
396
+ const disabled = props.disabled;
397
+ const dragState = useState(null);
398
+ const drag = dragState[0];
399
+ const setDrag = dragState[1];
400
+ /* The live value lives in a ref as well as in state: dragover fires many times
401
+ per frame, and a handler reading the rendered state would compute from a
402
+ stale list — that is what let the highlighted row drift away from the row
403
+ actually being dragged. */
404
+ const dragRef = useRef(null);
405
+ const handled = useRef(false);
406
+ /* While an HTML5 drag session runs, Chromium owns the pointer and stops
407
+ updating :hover — so the row that was under the cursor when the press
408
+ started keeps its hover tint, frozen at the position it was pressed at,
409
+ while the dragged row travels with the pointer. Hover paint is therefore
410
+ suppressed for the whole gesture (and until the pointer moves again, so a
411
+ frozen tint can never survive the drop). The dragged row keeps its own
412
+ accent border + opacity, so the feedback is not lost. */
413
+ const lockState = useState(false);
414
+ const hoverLocked = lockState[0];
415
+ const setHoverLocked = lockState[1];
416
+
417
+ useEffect(function () {
418
+ if (!hoverLocked) return undefined;
419
+ const release = function () { if (dragRef.current === null) setHoverLocked(false); };
420
+ window.addEventListener("pointermove", release, true);
421
+ window.addEventListener("mousemove", release, true);
422
+ window.addEventListener("pointerdown", release, true);
423
+ const timer = setTimeout(release, 400);
424
+ return function () {
425
+ window.removeEventListener("pointermove", release, true);
426
+ window.removeEventListener("mousemove", release, true);
427
+ window.removeEventListener("pointerdown", release, true);
428
+ clearTimeout(timer);
429
+ };
430
+ }, [hoverLocked]);
431
+
432
+
433
+ const shown = drag !== null ? drag.preview : ids;
434
+
435
+ function put(next) {
436
+ dragRef.current = next;
437
+ setDrag(next);
438
+ }
439
+
440
+ /* Identity, not position: the dragged row is remembered by its id, so the
441
+ highlight follows the row itself wherever the preview moves it. */
442
+ function start(id, event) {
443
+ if (disabled) return;
444
+ handled.current = false;
445
+ setHoverLocked(true);
446
+ /* Chromium focuses the mousedown target; a native drag then keeps it focused
447
+ for the rest of the gesture, which paints a focus background on the row. */
448
+ try { if (event && event.currentTarget && event.currentTarget.blur) event.currentTarget.blur(); } catch (e) {}
449
+ if (event.dataTransfer) {
450
+ event.dataTransfer.effectAllowed = "move";
451
+ try { event.dataTransfer.setData("text/plain", String(id)); } catch (e) {}
452
+ }
453
+ put({ id: id, preview: ids });
454
+ }
455
+
456
+ function over(rowId, event) {
457
+ const current = dragRef.current;
458
+ if (disabled || current === null) return;
459
+ event.preventDefault();
460
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
461
+ const list = current.preview;
462
+ const from = list.indexOf(current.id);
463
+ const index = list.indexOf(rowId);
464
+ if (from < 0 || index < 0) return;
465
+ const rect = event.currentTarget.getBoundingClientRect();
466
+ const below = event.clientY > rect.top + rect.height / 2;
467
+ let to = below ? index + 1 : index;
468
+ if (from < to) to -= 1;
469
+ to = Math.max(0, Math.min(list.length - 1, to));
470
+ if (to === from) return;
471
+ put({ id: current.id, preview: moved(list, from, to) });
472
+ }
473
+
474
+ /* Safety net: if a drag ends without reaching our drop handler (dropped on the
475
+ page background, cancelled, window blurred, source node re-rendered), the
476
+ highlight must not stay behind. Bubble phase, so a real drop still commits
477
+ first. */
478
+ useEffect(function () {
479
+ if (drag === null) return undefined;
480
+ const clear = function () {
481
+ dragRef.current = null;
482
+ setDrag(null);
483
+ handled.current = false;
484
+ };
485
+ window.addEventListener("dragend", clear);
486
+ window.addEventListener("drop", clear);
487
+ window.addEventListener("mouseup", clear);
488
+ window.addEventListener("blur", clear);
489
+ return function () {
490
+ window.removeEventListener("dragend", clear);
491
+ window.removeEventListener("drop", clear);
492
+ window.removeEventListener("mouseup", clear);
493
+ window.removeEventListener("blur", clear);
494
+ };
495
+ }, [drag === null]);
496
+
497
+ function clearFocus(node) {
498
+ try {
499
+ if (node && typeof node.blur === "function") node.blur();
500
+ const active = document.activeElement;
501
+ if (active && typeof active.blur === "function" && active !== document.body) active.blur();
502
+ } catch (e) {}
503
+ }
504
+
505
+ function drop(event) {
506
+ const current = dragRef.current;
507
+ if (current === null || handled.current) return;
508
+ handled.current = true;
509
+ event.preventDefault();
510
+ put(null);
511
+ clearFocus(event.currentTarget);
512
+ onCommit(current.preview);
513
+ }
514
+
515
+ function end(event) {
516
+ put(null);
517
+ handled.current = false;
518
+ clearFocus(event ? event.currentTarget : null);
519
+ }
520
+
521
+ return h("div", {
522
+ className: hoverLocked ? "dsh-mo-list dsh-mo-nohover" : "dsh-mo-list",
523
+ style: S.list,
524
+ onDragEnd: end,
525
+ onDrop: drop
526
+ },
527
+ shown.map(function (id, index) {
528
+ const dragging = drag !== null && drag.id === id;
529
+ return render(id, index, {
530
+ draggable: !disabled,
531
+ dragging: dragging,
532
+ onDragStart: function (event) { start(id, event); },
533
+ onDragOver: function (event) { over(id, event); },
534
+ onDragEnd: end,
535
+ onDrop: drop
536
+ });
537
+ })
538
+ );
539
+ }
540
+
541
+ /* ---------------- A. composer model selector, grouped by provider ---------- */
542
+ function ProviderGroupedModelSelect(props) {
543
+ const locked = props.locked;
544
+ const available = props.available;
545
+ const directory = props.directory;
546
+ const load = props.load;
547
+ const select = props.select;
548
+ const t = props.t;
549
+
550
+ const C = resolveOfficialClasses();
551
+ ensureOwnStyles(C);
552
+ const state = useSyncExternalStore(
553
+ function (fn) { return directory.subscribe(fn); },
554
+ function () { return directory.getSnapshot(); }
555
+ );
556
+ const openState = useState(false);
557
+ const open = openState[0];
558
+ const setOpen = openState[1];
559
+ const expandedState = useState({});
560
+ const expanded = expandedState[0];
561
+ const setExpanded = expandedState[1];
562
+ const queryState = useState("");
563
+ const query = queryState[0];
564
+ const setQuery = queryState[1];
565
+ const searchRef = useRef(null);
566
+ const toastState = useState(null);
567
+ const toast = toastState[0];
568
+ const setToast = toastState[1];
569
+ const toastSeq = useRef(0);
570
+ const posState = useState(null);
571
+ const menuPos = posState[0];
572
+ const setMenuPos = posState[1];
573
+ const paneState = useState("root");
574
+ const pane = paneState[0];
575
+ const setPane = paneState[1];
576
+ const rootRef = useRef(null);
577
+ const triggerRef = useRef(null);
578
+ const menuRef = useRef(null);
579
+ const idRef = useRef(null);
580
+ if (idRef.current === null) idRef.current = "mo-" + Math.random().toString(36).slice(2, 9);
581
+ /* Row registry for the shipped keyboard walk (↑/↓ cycles the shown pane's
582
+ rows). Refs are handed out during render, exactly like the shipped menu. */
583
+ const itemRefs = useRef([]);
584
+ const itemIndex = useRef(0);
585
+ const itemRef = function () {
586
+ const at = itemIndex.current++;
587
+ return function (node) { itemRefs.current[at] = node; };
588
+ };
589
+ itemIndex.current = 0;
590
+ itemRefs.current = [];
591
+
592
+ const groups = (state && state.groups) || [];
593
+ const failures = (state && state.failures) || [];
594
+ const current = (state && state.current) || null;
595
+ const busy = !!state && state.status === "selecting";
596
+ const loading = !!state && state.status === "loading";
597
+
598
+ /* Providers follow the plugin's own display preference (see below). Computed
599
+ per render — not memoised — so a reorder made in Settings shows up here the
600
+ next time the menu opens, without a page reload. */
601
+ const groupsOrdered = (function () {
602
+ const ids = [];
603
+ const byId = {};
604
+ for (let i = 0; i < groups.length; i++) { ids.push(groups[i].id); byId[groups[i].id] = groups[i]; }
605
+ const ordered = applyProviderOrder(ids, readProviderOrder());
606
+ const out = [];
607
+ for (let i = 0; i < ordered.length; i++) if (byId[ordered[i]] !== undefined) out.push(byId[ordered[i]]);
608
+ return out;
609
+ })();
610
+
611
+ /* Search, mirroring the shipped menu: offered only above four models and
612
+ only where the official sheet ships the search styling (0.2.0-rc.2+).
613
+ Matches rank through the shared rankByName, per group, empty groups
614
+ dropping out; a query forces every group open. */
615
+ const modelCount = (function () {
616
+ let n = 0;
617
+ for (let i = 0; i < groupsOrdered.length; i++) n += groupsOrdered[i].models.length;
618
+ return n;
619
+ })();
620
+ const showSearch = C !== null && C.hasSearchRow === true && RANK_BY_NAME !== null
621
+ && P !== null && isComponent(P.Input) && modelCount > 4;
622
+ const searchQuery = showSearch ? query.trim() : "";
623
+ const filteredGroups = (function () {
624
+ if (searchQuery === "") return groupsOrdered;
625
+ const out = [];
626
+ for (let i = 0; i < groupsOrdered.length; i++) {
627
+ const group = groupsOrdered[i];
628
+ const models = RANK_BY_NAME(group.models, searchQuery);
629
+ if (models.length === 0) continue;
630
+ const copy = {};
631
+ for (const key in group) copy[key] = group[key];
632
+ copy.models = models;
633
+ out.push(copy);
634
+ }
635
+ return out;
636
+ })();
637
+
638
+ const layoutKey = useMemo(function () {
639
+ const parts = [];
640
+ for (let i = 0; i < groupsOrdered.length; i++) parts.push(groupsOrdered[i].id + (expanded[groupsOrdered[i].id] === true ? "+" : "-"));
641
+ return parts.join("|") + "|" + String(current === null ? "" : current.provider);
642
+ }, [groupsOrdered, expanded, current]);
643
+
644
+ useEffect(function () { load(); }, []);
645
+
646
+ useEffect(function () {
647
+ if (!open) return undefined;
648
+ const onDown = function (event) {
649
+ if (triggerRef.current && triggerRef.current.contains(event.target)) return;
650
+ if (menuRef.current && menuRef.current.contains(event.target)) return;
651
+ setOpen(false);
652
+ };
653
+ const onKey = function (event) {
654
+ if (event.key !== "Escape") return;
655
+ setPane(function (currentPane) {
656
+ if (currentPane !== "root") return "root";
657
+ setOpen(false);
658
+ return currentPane;
659
+ });
660
+ };
661
+ document.addEventListener("mousedown", onDown);
662
+ document.addEventListener("keydown", onKey);
663
+ return function () {
664
+ document.removeEventListener("mousedown", onDown);
665
+ document.removeEventListener("keydown", onKey);
666
+ };
667
+ }, [open]);
668
+
669
+ /* Focus hand-off, mirroring the shipped menu: drilling hands focus to the
670
+ pane's first enabled row, returning to the root pane hands it back to
671
+ its first cell. A plain open/close does NOT move focus (it stays on the
672
+ trigger, like the shipped menu). */
673
+ const wasOpen = useRef(false);
674
+ useEffect(function () {
675
+ const navigating = wasOpen.current && open;
676
+ wasOpen.current = open;
677
+ if (!open || !navigating) return undefined;
678
+ if (pane === "model" && searchRef.current !== null && searchRef.current.focus) {
679
+ try { searchRef.current.focus(); } catch (e) {}
680
+ return undefined;
681
+ }
682
+ const items = itemRefs.current.filter(function (node) {
683
+ return node !== null && node !== undefined && node.disabled !== true;
684
+ });
685
+ if (items.length > 0) {
686
+ try { items[0].focus(); } catch (e) {}
687
+ }
688
+ return undefined;
689
+ }, [open, pane]);
690
+
691
+ const currentInfo = useMemo(function () {
692
+ if (current === null) return null;
693
+ for (let i = 0; i < groups.length; i++) {
694
+ const group = groups[i];
695
+ if (group.id !== current.provider) continue;
696
+ for (let j = 0; j < group.models.length; j++) {
697
+ if (group.models[j].id === current.model) return { group: group, model: group.models[j] };
698
+ }
699
+ }
700
+ return null;
701
+ }, [groups, current]);
702
+
703
+ /* Reasoning effort, mirroring the shipped ModelSelect: exact-model metadata
704
+ comes from the Host, and the effective level is the session's explicit
705
+ choice or the model's own default. */
706
+ const reasoning = currentInfo !== null && currentInfo.model.reasoning ? currentInfo.model.reasoning : undefined;
707
+ const effectiveEffort = current !== null && current.reasoningEffort !== undefined
708
+ ? current.reasoningEffort
709
+ : (reasoning !== undefined ? reasoning.defaultEffort : undefined);
710
+ let effortLabel;
711
+ if (reasoning !== undefined) {
712
+ if (effectiveEffort === undefined) effortLabel = t("composer.effortDefault");
713
+ else {
714
+ const levels = reasoning.efforts || [];
715
+ let found;
716
+ for (let i = 0; i < levels.length; i++) if (levels[i].id === effectiveEffort) found = levels[i];
717
+ effortLabel = found !== undefined ? found.name : effectiveEffort;
718
+ }
719
+ }
720
+ const effortChoices = useMemo(function () {
721
+ if (reasoning === undefined) return [];
722
+ const out = [];
723
+ if (reasoning.defaultEffort === undefined) out.push({ key: "provider-default", effort: undefined, label: t("composer.effortDefault") });
724
+ const levels = reasoning.efforts || [];
725
+ for (let i = 0; i < levels.length; i++) out.push({ key: "effort:" + levels[i].id, effort: levels[i].id, label: levels[i].name });
726
+ return out;
727
+ }, [reasoning, t]);
728
+
729
+ /* Official placement: right-aligned above the trigger, clamped to the
730
+ viewport, re-measured on scroll/resize (mirrors the shipped algorithm). */
731
+ useLayoutEffect(function () {
732
+ if (!open) { setMenuPos(null); return undefined; }
733
+ const place = function () {
734
+ const rect = triggerRef.current ? triggerRef.current.getBoundingClientRect() : undefined;
735
+ if (rect === undefined) return;
736
+ const MARGIN = 12;
737
+ const lw = menuRef.current ? menuRef.current.offsetWidth : 0;
738
+ const lh = menuRef.current ? menuRef.current.offsetHeight : 0;
739
+ let x = rect.right - lw;
740
+ let y = rect.top - 8 - lh;
741
+ if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN);
742
+ if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN);
743
+ setMenuPos({ left: x, top: y });
744
+ };
745
+ place();
746
+ window.addEventListener("scroll", place, true);
747
+ window.addEventListener("resize", place);
748
+ return function () {
749
+ window.removeEventListener("scroll", place, true);
750
+ window.removeEventListener("resize", place);
751
+ };
752
+ }, [open, pane, searchQuery, layoutKey]);
753
+
754
+ if (available === false) return null;
755
+
756
+ const disabled = !!locked;
757
+
758
+ function toggle() {
759
+ if (disabled) return;
760
+ if (!open) {
761
+ load();
762
+ setQuery("");
763
+ setPane(current === null ? "model" : "root");
764
+ if (Object.keys(expanded).length === 0 && current !== null) {
765
+ const seed = {};
766
+ seed[current.provider] = true;
767
+ setExpanded(seed);
768
+ }
769
+ } else {
770
+ setPane("root");
771
+ }
772
+ setOpen(!open);
773
+ }
774
+
775
+ function toggleGroup(id) {
776
+ const next = {};
777
+ for (const key in expanded) next[key] = expanded[key];
778
+ next[id] = !next[id];
779
+ setExpanded(next);
780
+ }
781
+
782
+ /* The shipped menu toasts a failed selection instead of closing; mirror
783
+ that — a silent failure reads as "nothing happened / it jumped back". */
784
+ function settleSelect(outcome) {
785
+ if (outcome === true) { setOpen(false); return; }
786
+ toastSeq.current += 1;
787
+ const message = outcome && outcome.code !== undefined
788
+ ? outcome.code + ": " + outcome.message
789
+ : String(outcome === false || outcome === undefined ? "unknown error" : outcome);
790
+ setToast({ seq: toastSeq.current, text: t("composer.selectFailed").split("{msg}").join(message) });
791
+ }
792
+
793
+ function pick(group, model) {
794
+ const sameModel = current !== null && current.provider === group.id && current.model === model.id;
795
+ let effort = model.reasoning ? model.reasoning.defaultEffort : undefined;
796
+ if (sameModel && current.reasoningEffort !== undefined) effort = current.reasoningEffort;
797
+ const selection = { provider: group.id, model: model.id };
798
+ if (effort !== undefined) selection.reasoningEffort = effort;
799
+ Promise.resolve(select(selection)).then(settleSelect);
800
+ }
801
+
802
+ /* Same model, new effort — the shipped semantics: an unchanged level just
803
+ closes, otherwise the whole selection is resubmitted. */
804
+ function chooseEffort(effort) {
805
+ if (current === null) return;
806
+ if (effectiveEffort === effort) { setOpen(false); return; }
807
+ const selection = { provider: current.provider, model: current.model };
808
+ if (effort !== undefined) selection.reasoningEffort = effort;
809
+ Promise.resolve(select(selection)).then(settleSelect);
810
+ }
811
+
812
+ const label = currentInfo !== null ? currentInfo.model.name : (current === null ? t("composer.none") : current.provider + "/" + current.model);
813
+ const triggerTitle = effortLabel === undefined ? label : label + " · " + effortLabel;
814
+ const aria = (effortLabel === undefined ? t("composer.aria") : t("composer.ariaEffort"))
815
+ .split("{model}").join(label)
816
+ .split("{effort}").join(effortLabel === undefined ? "" : effortLabel);
817
+
818
+ const chevronIcon = icon("IconChevronDownOutline14", {
819
+ className: C !== null ? cx(C.chevron, open && C.chevronOpen) : undefined,
820
+ style: C === null ? S.caret : undefined
821
+ });
822
+ const triggerChildren = [];
823
+ const triggerGlyph = icon("IconDataOutline16", { key: "g", className: C !== null ? C.triggerIcon : undefined, size: 16 });
824
+ if (triggerGlyph !== null) triggerChildren.push(triggerGlyph);
825
+ triggerChildren.push(h("span", { key: "l", className: C !== null ? C.triggerLabel : undefined, style: C === null ? S.chipLabel : undefined }, label));
826
+ if (effortLabel !== undefined) {
827
+ triggerChildren.push(h("span", { key: "e", className: C !== null ? C.triggerEffort : undefined, style: C === null ? S.chipEffort : undefined }, effortLabel));
828
+ }
829
+ triggerChildren.push(
830
+ busy && isComponent(P !== null ? P.StateDot : null)
831
+ ? icon("StateDot", { key: "c", state: "ongoing" })
832
+ : (chevronIcon !== null ? chevronIcon : h("span", { key: "c", style: S.caret }, open ? "▾" : "▴"))
833
+ );
834
+
835
+ const trigger = h("button", {
836
+ ref: triggerRef,
837
+ type: "button",
838
+ className: C !== null ? C.trigger : undefined,
839
+ style: C === null ? (disabled ? Object.assign({}, S.chip, S.chipDisabled) : S.chip) : undefined,
840
+ disabled: disabled,
841
+ title: disabled ? t("composer.locked") : triggerTitle,
842
+ "aria-label": aria,
843
+ "aria-haspopup": "menu",
844
+ "aria-expanded": open,
845
+ "aria-controls": open ? idRef.current + "-menu" : undefined,
846
+ onClick: toggle
847
+ }, triggerChildren);
848
+
849
+ /* Keyboard walk mirrors the shipped menu: ↑/↓ cycle the rows of the shown
850
+ pane. (Escape is handled at document level above; Tab quirks are left to
851
+ the browser.) */
852
+ function onRootKeyDown(event) {
853
+ if (!open) return;
854
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
855
+ event.preventDefault();
856
+ const items = itemRefs.current.filter(function (node) {
857
+ return node !== null && node !== undefined && node.disabled !== true;
858
+ });
859
+ if (items.length === 0) return;
860
+ const active = items.indexOf(document.activeElement);
861
+ const step = event.key === "ArrowDown" ? 1 : -1;
862
+ const next = active === -1
863
+ ? (step === 1 ? 0 : items.length - 1)
864
+ : (active + step + items.length) % items.length;
865
+ items[next].focus();
866
+ }
867
+
868
+ /* The portaled menu must stay a React CHILD of this wrapper: portals
869
+ bubble synthetic events through the REACT tree, not the DOM tree, so
870
+ onKeyDown on the wrapper catches keys pressed inside the menu — exactly
871
+ how the shipped root div wraps trigger + portal. A sibling portal would
872
+ leave the menu's rows keyboard-dead. */
873
+ function rootDiv(children) {
874
+ return h("div", {
875
+ ref: rootRef,
876
+ className: C !== null ? C.root : undefined,
877
+ style: C === null ? { minWidth: 0, position: "relative" } : undefined,
878
+ onKeyDown: onRootKeyDown
879
+ }, children);
880
+ }
881
+
882
+ if (!open) return rootDiv(trigger);
883
+
884
+ const groupNodes = filteredGroups.map(function (group) {
885
+ /* A query forces every group open: the matches must be visible without
886
+ hunting for collapse states; clearing restores the saved state. */
887
+ const isOpen = searchQuery !== "" ? true : expanded[group.id] === true;
888
+ const headingId = idRef.current + "-" + group.id;
889
+ /* A provider row reuses the shipped `cell` drill-in row (label + right-aligned
890
+ value + chevron), so both menu levels are drawn by the shipped sheet. */
891
+ const headChildren = [
892
+ h("span", { key: "n", className: C !== null ? C.cellLabel : undefined, style: C === null ? undefined : undefined }, group.name || group.id),
893
+ h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.groupCount : undefined }, group.models.length + " " + t("composer.count"))
894
+ ];
895
+ const groupChevron = icon("IconChevronRightOutline14", {
896
+ key: "i",
897
+ className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
898
+ style: C === null ? S.groupChevron : undefined
899
+ });
900
+ if (groupChevron !== null) headChildren.push(groupChevron);
901
+ const head = h("button", {
902
+ key: "h",
903
+ ref: itemRef(),
904
+ type: "button",
905
+ id: headingId,
906
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
907
+ style: C === null ? S.fallbackGroupTitle : undefined,
908
+ "aria-expanded": isOpen,
909
+ onClick: function () { toggleGroup(group.id); }
910
+ }, headChildren);
911
+
912
+ const items = !isOpen ? [] : group.models.map(function (model) {
913
+ const selected = current !== null && current.provider === group.id && current.model === model.id;
914
+ const check = selected ? icon("IconCheckOutline16", { key: "k" }) : null;
915
+ return h("button", {
916
+ key: model.id,
917
+ ref: itemRef(),
918
+ type: "button",
919
+ role: "menuitemradio",
920
+ "aria-checked": selected,
921
+ className: C !== null ? cx(C.option, "dsh-mo-model", selected && C.selected) : "dsh-mo-model",
922
+ style: C === null ? S.fallbackModel : undefined,
923
+ title: model.name,
924
+ disabled: busy,
925
+ onClick: function () { pick(group, model); }
926
+ },
927
+ h("span", { className: C !== null ? C.optionCopy : undefined, style: C === null ? S.fallbackCopy : undefined },
928
+ h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.chipLabel : undefined }, model.name || model.id)),
929
+ h("span", { className: C !== null ? C.check : undefined, style: C === null ? S.fallbackCheck : undefined }, check)
930
+ );
931
+ });
932
+
933
+ return h("section", {
934
+ key: group.id,
935
+ role: "group",
936
+ "aria-labelledby": headingId,
937
+ className: C !== null ? C.group : S.fallbackGroup
938
+ }, [head].concat(items));
939
+ });
940
+
941
+ /* Root pane = the shipped Model / Effort row pair; the "model" pane keeps
942
+ OUR improvement (providers grouped, collapsible, in the saved order) on
943
+ top of the shipped list styling; the "effort" pane is the shipped radio
944
+ list. No back rows: the shipped menu returns with Escape (or Shift+Tab)
945
+ and closes on pick or outside click, and so does this one. */
946
+ const paneChildren = [];
947
+ if (pane === "root") {
948
+ paneChildren.push(h("button", {
949
+ key: "model",
950
+ ref: itemRef(),
951
+ type: "button",
952
+ role: "menuitem",
953
+ className: C !== null ? C.cell : undefined,
954
+ style: C === null ? S.fallbackGroupTitle : undefined,
955
+ onClick: function () { setQuery(""); setPane("model"); }
956
+ },
957
+ h("span", { key: "l", className: C !== null ? C.cellLabel : undefined }, t("composer.menuModel")),
958
+ h("span", { key: "v", className: C !== null ? C.cellValue : undefined }, label),
959
+ icon("IconChevronRightOutline14", { key: "c", className: C !== null ? C.cellChevron : undefined })
960
+ ));
961
+ if (reasoning !== undefined) {
962
+ paneChildren.push(h("button", {
963
+ key: "effort",
964
+ ref: itemRef(),
965
+ type: "button",
966
+ role: "menuitem",
967
+ className: C !== null ? C.cell : undefined,
968
+ style: C === null ? S.fallbackGroupTitle : undefined,
969
+ onClick: function () { setPane("effort"); }
970
+ },
971
+ h("span", { key: "l", className: C !== null ? C.cellLabel : undefined }, t("composer.effort")),
972
+ h("span", { key: "v", className: C !== null ? C.cellValue : undefined }, effortLabel),
973
+ icon("IconChevronRightOutline14", { key: "c", className: C !== null ? C.cellChevron : undefined })
974
+ ));
975
+ }
976
+ } else if (pane === "effort") {
977
+ if (effortChoices.length === 0) {
978
+ paneChildren.push(h("div", { key: "ee", className: C !== null ? C.empty : undefined, style: C === null ? S.menuEmpty : undefined }, t("composer.effortEmpty")));
979
+ } else {
980
+ paneChildren.push(h("div", {
981
+ key: "eg",
982
+ className: C !== null ? cx(C.groups, "scrollable") : undefined,
983
+ style: C === null ? S.fallbackGroups : undefined
984
+ }, effortChoices.map(function (level) {
985
+ const selected = effectiveEffort === level.effort;
986
+ const check = selected ? icon("IconCheckOutline16", { key: "k" }) : null;
987
+ return h("button", {
988
+ key: level.key,
989
+ ref: itemRef(),
990
+ type: "button",
991
+ role: "menuitemradio",
992
+ "aria-checked": selected,
993
+ className: C !== null ? cx(C.option, selected && C.selected) : undefined,
994
+ style: C === null ? S.fallbackModel : undefined,
995
+ disabled: busy,
996
+ onClick: function () { chooseEffort(level.effort); }
997
+ },
998
+ h("span", { className: C !== null ? C.optionCopy : undefined, style: C === null ? S.fallbackCopy : undefined },
999
+ h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.chipLabel : undefined }, level.label)),
1000
+ h("span", { className: C !== null ? C.check : undefined, style: C === null ? S.fallbackCheck : undefined }, check)
1001
+ );
1002
+ })));
1003
+ }
1004
+ } else {
1005
+ if (showSearch) {
1006
+ /* Pinned search row with the shipped styling: the Input primitive plus
1007
+ the search classes; the clear button only rides along with a query. */
1008
+ paneChildren.push(h("div", { key: "sr", className: C.searchRow },
1009
+ h(P.Input, {
1010
+ key: "in",
1011
+ ref: searchRef,
1012
+ className: cx(C.search, searchQuery !== "" && C.searchWithQuery),
1013
+ type: "text",
1014
+ role: "searchbox",
1015
+ "aria-label": t("composer.searchPlaceholder"),
1016
+ placeholder: t("composer.searchPlaceholder"),
1017
+ value: query,
1018
+ onChange: function (event) { setQuery(event.target.value); }
1019
+ }),
1020
+ searchQuery !== "" ? h("button", {
1021
+ key: "clr",
1022
+ type: "button",
1023
+ className: C.searchClear,
1024
+ "aria-label": t("composer.searchClear"),
1025
+ disabled: busy,
1026
+ onClick: function () {
1027
+ setQuery("");
1028
+ if (searchRef.current !== null && searchRef.current.focus) searchRef.current.focus();
1029
+ }
1030
+ }, icon("IconCloseFillRegular", {})) : null
1031
+ ));
1032
+ }
1033
+ if (loading) paneChildren.push(h("div", { key: "s", className: C !== null ? C.status : undefined, style: C === null ? S.boxHint : undefined }, t("composer.refreshing")));
1034
+ if (failures.length > 0) {
1035
+ paneChildren.push(h("div", { key: "w", className: C !== null ? C.warning : undefined, style: C === null ? S.failure : undefined },
1036
+ h("span", null, failures.map(function (f) { return f.name + ": " + f.message; }).join(" · "))));
1037
+ }
1038
+ if (filteredGroups.length === 0) {
1039
+ if (!loading) paneChildren.push(h("div", { key: "e", className: C !== null ? C.empty : undefined, style: C === null ? S.menuEmpty : undefined },
1040
+ searchQuery !== "" ? t("composer.searchEmpty") : t("composer.empty")));
1041
+ } else {
1042
+ paneChildren.push(h("div", {
1043
+ key: "g",
1044
+ className: C !== null ? cx(C.groups, "scrollable") : undefined,
1045
+ style: C === null ? S.fallbackGroups : undefined
1046
+ }, groupNodes));
1047
+ }
1048
+ }
1049
+
1050
+ /* The shipped menus draw their surface through the MenuSurface primitive
1051
+ (translucent fill + backdrop blur + radius; the `_menu` class only adds
1052
+ geometry). Rendering through it is what makes the frost identical — a
1053
+ translucent fill without the blur reads as "see-through", not "frosted".
1054
+ Without the primitive, fall back to resolving the menu token from
1055
+ <body> and painting it inline (the pre-0.2 workaround). */
1056
+ const menuProps = {
1057
+ ref: menuRef,
1058
+ id: idRef.current + "-menu",
1059
+ className: C !== null ? C.menu : undefined,
1060
+ style: menuPos !== null ? menuPos : MEASURE_STYLE,
1061
+ role: "menu",
1062
+ "aria-label": t("composer.title"),
1063
+ "aria-busy": loading || busy
1064
+ };
1065
+ const menu = MENU_SURFACE !== null
1066
+ ? h(MENU_SURFACE, menuProps, paneChildren)
1067
+ : h("div", Object.assign({}, menuProps, {
1068
+ style: Object.assign({
1069
+ background: (function () {
1070
+ try {
1071
+ const v = getComputedStyle(document.body).getPropertyValue("--dsw-specific-menu").trim();
1072
+ if (v.length > 0) return v;
1073
+ } catch (e) {}
1074
+ return "#26262a";
1075
+ })(),
1076
+ /* Frost parity with MenuSurface even on this path: the theme's
1077
+ blur token, with the shipped value as fallback. Without it a
1078
+ fallback menu reads see-through next to the shipped ones. */
1079
+ backdropFilter: "var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%))",
1080
+ WebkitBackdropFilter: "var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%))",
1081
+ borderRadius: "var(--dsw-radius-lg, 16px)"
1082
+ },
1083
+ C !== null ? (menuPos !== null ? menuPos : MEASURE_STYLE)
1084
+ : Object.assign({}, S.fallbackMenu, menuPos !== null ? menuPos : MEASURE_STYLE))
1085
+ }), paneChildren);
1086
+
1087
+ const toastNode = toast !== null && P !== null && isComponent(P.Toast)
1088
+ ? h(P.Toast, {
1089
+ key: toast.seq,
1090
+ text: toast.text,
1091
+ icon: icon("IconWarningOutlineRegular", {}),
1092
+ anchor: rootRef.current !== null && rootRef.current.closest ? rootRef.current.closest("[data-composer-card]") : null,
1093
+ onDone: function () { setToast(null); }
1094
+ })
1095
+ : null;
1096
+ return rootDiv([trigger, ReactDOM.createPortal(menu, document.body), toastNode]);
1097
+ }
1098
+
1099
+ /* ------------- B. one provider card's model order editor ------------------ */
1100
+ /* Owner props: { provider, configured, keyConfigured }. Injected: store, namespace, mutate, t. */
1101
+ function ProviderModelOrder(props) {
1102
+ const C = resolveOfficialClasses();
1103
+ ensureOwnStyles(C);
1104
+ const entry = props.provider;
1105
+ const store = props.store;
1106
+ const namespace = props.namespace;
1107
+ const mutate = props.mutate;
1108
+ const t = props.t;
1109
+
1110
+ const snap = useSyncExternalStore(
1111
+ function (fn) { return store.subscribe(fn); },
1112
+ function () { return store.getSnapshot(); }
1113
+ );
1114
+ const view = snap ? snap.view : undefined;
1115
+ const writable = !!(view && view.writable);
1116
+ const nsView = namespaceView(view, namespace);
1117
+ const providerId = entry ? entry.provider : undefined;
1118
+ const layer = providerLayer(nsView);
1119
+ const resolved = (nsView && nsView.value && nsView.value.providers) || {};
1120
+ const row = providerId !== undefined ? (layer[providerId] || resolved[providerId]) : undefined;
1121
+ const models = (row && row.models) || [];
1122
+ const baseIds = models.map(function (m) { return m.id; });
1123
+ const sourceKey = baseIds.join("|");
1124
+ const byId = {};
1125
+ for (let i = 0; i < models.length; i++) byId[models[i].id] = models[i];
1126
+
1127
+ const orderState = useState(null);
1128
+ const order = orderState[0];
1129
+ const setOrder = orderState[1];
1130
+ const savingState = useState(false);
1131
+ const saving = savingState[0];
1132
+ const setSaving = savingState[1];
1133
+ const notePair = useNote();
1134
+ const note = notePair[0];
1135
+ const setNote = notePair[1];
1136
+ const expandState = useState(false);
1137
+ const expanded = expandState[0];
1138
+ const setExpanded = expandState[1];
1139
+
1140
+ useEffect(function () { setOrder(null); }, [providerId, sourceKey]);
1141
+
1142
+ const ids = order === null ? baseIds : order;
1143
+ const dirty = order !== null && order.join("|") !== sourceKey;
1144
+
1145
+ function commit(next) {
1146
+ if (next.join("|") === baseIds.join("|")) { setOrder(null); return; }
1147
+ setOrder(next);
1148
+ if (!writable || saving) return;
1149
+ setSaving(true);
1150
+ const basePath = entry && entry.settingsPath ? entry.settingsPath.slice() : ["providers", providerId];
1151
+ const path = basePath.concat(["models"]);
1152
+ const revision = nsView ? nsView.revision : undefined;
1153
+ let pending;
1154
+ try {
1155
+ pending = mutate([{ op: "set", path: path, value: next.map(function (id) { return byId[id]; }) }], revision);
1156
+ } catch (e) {
1157
+ setSaving(false);
1158
+ setNote(String((e && e.message) || e));
1159
+ setTimeout(function () { setNote(null); }, 4000);
1160
+ return;
1161
+ }
1162
+ Promise.resolve(pending).then(function (res) {
1163
+ setSaving(false);
1164
+ if (res && res.ok) { setOrder(null); setNote("ok"); }
1165
+ else setNote((res && res.error && res.error.message) || "save failed");
1166
+ setTimeout(function () { setNote(null); }, 2600);
1167
+ }, function (err) {
1168
+ setSaving(false);
1169
+ setNote(String((err && err.message) || err));
1170
+ setTimeout(function () { setNote(null); }, 2600);
1171
+ });
1172
+ }
1173
+
1174
+ /* The embedded list also renders a lone model, so expanding a single-model
1175
+ provider shows what it holds instead of nothing. */
1176
+ const minModels = props.embedded === true ? 1 : 2;
1177
+ if (!entry || models.length < minModels) return null;
1178
+
1179
+ /* Embedded mode: the footer panel owns the surrounding provider row and its
1180
+ expand state, so only the draggable model list is rendered here. */
1181
+ if (props.embedded === true) {
1182
+ return h(DragList, {
1183
+ ids: ids,
1184
+ disabled: !writable || saving,
1185
+ onCommit: commit,
1186
+ render: function (id, index, dragProps) {
1187
+ const model = byId[id];
1188
+ return h("div", {
1189
+ key: id,
1190
+ draggable: dragProps.draggable,
1191
+ "data-mo": "model-row",
1192
+ "data-mo-id": id,
1193
+ "data-mo-provider": String(providerId),
1194
+ tabIndex: -1,
1195
+ onDragStart: dragProps.onDragStart,
1196
+ onDragOver: dragProps.onDragOver,
1197
+ onDragEnd: dragProps.onDragEnd,
1198
+ onDrop: dragProps.onDrop,
1199
+ className: C !== null ? cx(C.option, "dsh-mo-model") : "dsh-mo-model",
1200
+ style: dragProps.dragging
1201
+ ? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
1202
+ : (C !== null ? undefined : S.row)
1203
+ },
1204
+
1205
+ h("span", { key: "n", className: C !== null ? C.optionCopy : undefined, style: C === null ? { flex: "1 1 auto", minWidth: 0, display: "flex" } : undefined },
1206
+ h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.rowName : undefined }, (model && (model.name || model.id)) || id))
1207
+ );
1208
+ }
1209
+ });
1210
+ }
1211
+
1212
+ /* Header doubles as the collapse control, drawn by the shipped `cell` row.
1213
+ Status notes ride the header's right edge so the list never reflows. */
1214
+ const headChildren = [
1215
+ h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("order.title")),
1216
+ h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.boxHint : undefined }, saving ? t("order.saving") : t("order.hint"))
1217
+ ];
1218
+ const notes = [];
1219
+ if (!writable) notes.push(h("span", { key: "ro", style: S.dirty }, t("order.readonly")));
1220
+ if (dirty) notes.push(h("span", { key: "d", style: S.dirty }, t("order.unsaved")));
1221
+ if (note === "ok") notes.push(h("span", { key: "ok", style: S.ok }, t("order.saved")));
1222
+ else if (note !== null) notes.push(h("span", { key: "n", style: S.dirty }, note));
1223
+ for (let i = 0; i < notes.length; i++) headChildren.push(notes[i]);
1224
+ const headChevron = icon("IconChevronRightOutline14", {
1225
+ key: "c",
1226
+ className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
1227
+ style: C === null ? S.groupChevron : undefined
1228
+ });
1229
+ if (headChevron !== null) headChildren.push(headChevron);
1230
+
1231
+ const head = h("button", {
1232
+ type: "button",
1233
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
1234
+ style: C === null ? S.boxHead : undefined,
1235
+ "aria-expanded": expanded,
1236
+ onClick: function () { setExpanded(!expanded); }
1237
+ }, headChildren);
1238
+
1239
+ const list = h(DragList, {
1240
+ ids: ids,
1241
+ disabled: !writable || saving,
1242
+ onCommit: commit,
1243
+ render: function (id, index, dragProps) {
1244
+ const model = byId[id];
1245
+ return h("div", {
1246
+ key: id,
1247
+ draggable: dragProps.draggable,
1248
+ "data-mo": "model-row",
1249
+ "data-mo-id": id,
1250
+ "data-mo-provider": String(providerId),
1251
+ tabIndex: -1,
1252
+ onDragStart: dragProps.onDragStart,
1253
+ onDragOver: dragProps.onDragOver,
1254
+ onDragEnd: dragProps.onDragEnd,
1255
+ onDrop: dragProps.onDrop,
1256
+ className: C !== null ? cx(C.option, "dsh-mo-row") : undefined,
1257
+ style: dragProps.dragging
1258
+ ? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
1259
+ : (C !== null ? undefined : S.row)
1260
+ },
1261
+
1262
+ h("span", { key: "n", className: C !== null ? C.optionCopy : undefined, style: C === null ? undefined : { flex: "1 1 auto", minWidth: 0, display: "flex" } },
1263
+ h("span", { className: C !== null ? C.modelName : undefined, style: C === null ? S.rowName : undefined }, (model && (model.name || model.id)) || id))
1264
+ );
1265
+ }
1266
+ });
1267
+
1268
+ return h("div", { style: S.box }, head, expanded ? list : null);
1269
+ }
1270
+
1271
+ /* ------------- C. provider order panel (models page footer) --------------- */
1272
+ /* Provider order is a DISPLAY preference owned by this plugin: the settings
1273
+ document stores `providers` as a record whose key order the host normalises
1274
+ away (verified with both a whole-record `set` and an `unset`+`set` pair), so
1275
+ the order cannot live there. It is kept per browser and applied to the
1276
+ composer menu this plugin renders. */
1277
+ const ORDER_STORAGE_KEY = "dsh-model-organizer.providerOrder";
1278
+
1279
+ function readProviderOrder() {
1280
+ try {
1281
+ const raw = window.localStorage.getItem(ORDER_STORAGE_KEY);
1282
+ if (raw === null) return [];
1283
+ const parsed = JSON.parse(raw);
1284
+ if (!Array.isArray(parsed)) return [];
1285
+ const out = [];
1286
+ for (let i = 0; i < parsed.length; i++) if (typeof parsed[i] === "string") out.push(parsed[i]);
1287
+ return out;
1288
+ } catch (e) { return []; }
1289
+ }
1290
+
1291
+ function writeProviderOrder(ids) {
1292
+ try { window.localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)); } catch (e) {}
1293
+ }
1294
+
1295
+ const PANEL_STORAGE_KEY = "dsh-model-organizer.panelOpen";
1296
+ const PANEL_POS_KEY = "dsh-model-organizer.panelPos";
1297
+
1298
+
1299
+ function readPanelPos() {
1300
+ try {
1301
+ const raw = window.localStorage.getItem(PANEL_POS_KEY);
1302
+ if (raw === null) return null;
1303
+ const parsed = JSON.parse(raw);
1304
+ if (parsed === null || typeof parsed !== "object") return null;
1305
+ if (typeof parsed.left !== "number" || typeof parsed.top !== "number") return null;
1306
+ return { left: parsed.left, top: parsed.top };
1307
+ } catch (e) { return null; }
1308
+ }
1309
+
1310
+ function writePanelPos(pos) {
1311
+ try { window.localStorage.setItem(PANEL_POS_KEY, JSON.stringify(pos)); } catch (e) {}
1312
+ }
1313
+
1314
+ /* ALWAYS expanded on mount. A remembered collapse is indistinguishable from
1315
+ "the default is collapsed" the next time the page opens, which is not what
1316
+ this panel is for. */
1317
+ function readPanelOpen() {
1318
+ return true;
1319
+ }
1320
+
1321
+ function writePanelOpen(open) {
1322
+ try { window.localStorage.setItem(PANEL_STORAGE_KEY, open ? "1" : "0"); } catch (e) {}
1323
+ }
1324
+
1325
+ /* Stored order first, then everything else in document order. */
1326
+ function applyProviderOrder(ids, preferred) {
1327
+ if (preferred.length === 0) return ids;
1328
+ const rank = {};
1329
+ for (let i = 0; i < preferred.length; i++) rank[preferred[i]] = i;
1330
+ const known = [];
1331
+ const rest = [];
1332
+ for (let i = 0; i < ids.length; i++) {
1333
+ if (rank[ids[i]] === undefined) rest.push(ids[i]);
1334
+ else known.push(ids[i]);
1335
+ }
1336
+ known.sort(function (a, b) { return rank[a] - rank[b]; });
1337
+ return known.concat(rest);
1338
+ }
1339
+
1340
+ /* A seat entry that throws is silently abdicated, so "my panel is missing" and
1341
+ "my panel threw" look identical from the page. This boundary turns the throw
1342
+ into one visible line, so the failure can be reported instead of guessed. */
1343
+ class PanelBoundary extends React.Component {
1344
+ constructor(props) {
1345
+ super(props);
1346
+ this.state = { err: null };
1347
+ }
1348
+ static getDerivedStateFromError(err) {
1349
+ return { err: err };
1350
+ }
1351
+ render() {
1352
+ if (this.state.err !== null) {
1353
+ const message = String((this.state.err && this.state.err.message) || this.state.err);
1354
+ return h("div", {
1355
+ "data-mo": "panel-error",
1356
+ style: {
1357
+ margin: "8px 0", padding: "6px 10px", borderRadius: 8, fontSize: 12, lineHeight: "18px",
1358
+ whiteSpace: "pre-wrap",
1359
+ border: "1px solid var(--dsw-alias-state-error-primary, #f85149)",
1360
+ color: "var(--dsw-alias-state-error-primary, #f85149)"
1361
+ }
1362
+ }, "dsh-model-organizer 渲染失败:" + message);
1363
+ }
1364
+ return this.props.children;
1365
+ }
1366
+ }
1367
+
1368
+ function ProviderOrderPanelGuarded(props) {
1369
+ return h(PanelBoundary, null, h(ProviderOrderPanel, props));
1370
+ }
1371
+
1372
+ function ProviderGroupedModelSelectGuarded(props) {
1373
+ return h(PanelBoundary, null, h(ProviderGroupedModelSelect, props));
1374
+ }
1375
+
1376
+ function ProviderOrderPanel(props) {
1377
+ const C = resolveOfficialClasses();
1378
+ ensureOwnStyles(C);
1379
+ const store = props.store;
1380
+ const namespace = props.namespace;
1381
+ const mutate = props.mutate;
1382
+ const t = props.t;
1383
+
1384
+ const snap = useSyncExternalStore(
1385
+ function (fn) { return store.subscribe(fn); },
1386
+ function () { return store.getSnapshot(); }
1387
+ );
1388
+ const view = snap ? snap.view : undefined;
1389
+ const nsView = namespaceView(view, namespace);
1390
+ const display = orderLayer(nsView) || {};
1391
+ const documentIds = Object.keys(display);
1392
+ const storedState = useState(readProviderOrder);
1393
+ const stored = storedState[0];
1394
+ const setStored = storedState[1];
1395
+ const openState = useState({});
1396
+ const openMap = openState[0];
1397
+ const setOpenMap = openState[1];
1398
+ const panelState = useState(readPanelOpen);
1399
+ const panelOpen = panelState[0];
1400
+ const setPanelOpen = function (next) { writePanelOpen(next); panelState[1](next); };
1401
+ const posState = useState(readPanelPos);
1402
+ const panelPos = posState[0];
1403
+ const setPanelPos = posState[1];
1404
+ const panelRef = useRef(null);
1405
+ const pressRef = useRef(null);
1406
+ const suppressClick = useRef(false);
1407
+ const movingState = useState(false);
1408
+ const moving = movingState[0];
1409
+ const setMoving = movingState[1];
1410
+ const baseIds = applyProviderOrder(documentIds, stored);
1411
+ const sourceKey = baseIds.join("|");
1412
+
1413
+ const orderState = useState(null);
1414
+ const order = orderState[0];
1415
+ const setOrder = orderState[1];
1416
+ const savingState = useState(false);
1417
+ const saving = savingState[0];
1418
+ const setSaving = savingState[1];
1419
+ const notePair = useNote();
1420
+ const note = notePair[0];
1421
+ const setNote = notePair[1];
1422
+
1423
+ useEffect(function () { setOrder(null); }, [sourceKey]);
1424
+
1425
+ /* Mirror the saved order onto the settings page's provider cards: the
1426
+ shipped list is a flex column, so CSS `order` re-ranks it in place.
1427
+ Cards carry no provider identity in the DOM — match through the
1428
+ displayName text each card renders. */
1429
+ const seatRef = useRef(null);
1430
+ const mirrorCards = function () {
1431
+ try {
1432
+ const seat = seatRef.current;
1433
+ if (seat === null) return;
1434
+ /* The seat may sit inside slot wrappers between it and the section —
1435
+ climb until a container actually holds the card list. */
1436
+ let list = null;
1437
+ let el = seat;
1438
+ for (let hop = 0; hop < 6 && el !== null && list === null; hop++) {
1439
+ el = el.parentElement;
1440
+ if (el !== null) list = el.querySelector("ul");
1441
+ }
1442
+ if (list === null) return;
1443
+ const byName = {};
1444
+ for (const id in display) {
1445
+ const profile = display[id] || {};
1446
+ const name = profile.displayName || id;
1447
+ if (byName[name] === undefined) byName[name] = id;
1448
+ }
1449
+ /* No usable identity mapping — leave the cards alone rather than
1450
+ pretending to reorder. */
1451
+ if (Object.keys(byName).length === 0) return;
1452
+ const rank = {};
1453
+ baseIds.forEach(function (id, index) { rank[id] = index + 1; });
1454
+ let unknown = 1000;
1455
+ for (let i = 0; i < list.children.length; i++) {
1456
+ const li = list.children[i];
1457
+ const nameEl = li.querySelector ? li.querySelector('[class*="rowName"]') : null;
1458
+ const name = nameEl !== null ? nameEl.textContent : null;
1459
+ const id = name !== null && byName[name] !== undefined ? byName[name] : null;
1460
+ if (id !== null && rank[id] !== undefined) li.style.order = String(rank[id]);
1461
+ else li.style.order = String(unknown++);
1462
+ }
1463
+ } catch (e) {}
1464
+ };
1465
+
1466
+ useEffect(function () {
1467
+ mirrorCards();
1468
+ const seat = seatRef.current;
1469
+ const container = seat !== null ? seat.parentElement : null;
1470
+ const list = container !== null ? container.querySelector("ul") : null;
1471
+ if (list === null || typeof MutationObserver !== "function") return undefined;
1472
+ const observer = new MutationObserver(function () { mirrorCards(); });
1473
+ observer.observe(list, { childList: true });
1474
+ return function () { observer.disconnect(); };
1475
+ }, [sourceKey, stored]);
1476
+
1477
+ useLayoutEffect(function () {
1478
+ if (panelPos !== null || panelRef.current === null) return;
1479
+ const rect = panelRef.current.getBoundingClientRect();
1480
+ setPanelPos({ left: Math.round(rect.left), top: Math.round(rect.top) });
1481
+ }, [panelPos, panelOpen]);
1482
+
1483
+ const ids = order === null ? baseIds : order;
1484
+ const idsKey = ids.join("|");
1485
+ const dirty = order !== null && order.join("|") !== sourceKey;
1486
+
1487
+ /* The shipped provider cards are left exactly as React renders them. Ordering used
1488
+ to be mirrored onto them with inline `order`; that is gone — this plugin adds a
1489
+ floating panel to the Models page and nothing else. */
1490
+ function commit(next) {
1491
+ if (next.join("|") === baseIds.join("|")) { setOrder(null); return; }
1492
+ writeProviderOrder(next);
1493
+ setStored(next);
1494
+ setOrder(null);
1495
+ setNote("ok");
1496
+ setTimeout(function () { setNote(null); }, 2200);
1497
+ }
1498
+
1499
+ if (baseIds.length < 2) return null;
1500
+
1501
+ const notes = [];
1502
+ if (note === "ok") notes.push(h("span", { key: "ok", style: S.ok }, t("providers.saved")));
1503
+ else if (note !== null) notes.push(h("span", { key: "n", style: S.dirty }, note));
1504
+
1505
+ /* Same shipped-cell header as every other row in this page: the title, the
1506
+ hint, then any status note on the right edge. */
1507
+ const headChildren = [
1508
+ h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("providers.title"))
1509
+ ];
1510
+ /* Status notes sit immediately after the title; the hint keeps the right edge. */
1511
+ for (let i = 0; i < notes.length; i++) headChildren.push(notes[i]);
1512
+ headChildren.push(h("span", { key: "v", className: C !== null ? C.cellValue : undefined, style: C === null ? S.boxHint : undefined }, t("providers.hint")));
1513
+ /* One row, two gestures — exactly like the rows below: a press that does not
1514
+ travel is a CLICK (toggle), a press that travels is a DRAG (move the panel).
1515
+ Arming on MOVEMENT only means a slow click still toggles; pinning the measured
1516
+ top-left on arming keeps the first movement from jumping. */
1517
+ function armMove(token) {
1518
+ if (token.armed) return;
1519
+ token.armed = true;
1520
+ setMoving(true);
1521
+ setPanelPos(token.base);
1522
+ }
1523
+
1524
+ function moveDown(event) {
1525
+ if (event.button !== undefined && event.button !== 0) return;
1526
+ const rect = panelRef.current ? panelRef.current.getBoundingClientRect() : null;
1527
+ const base = rect === null ? { left: 0, top: 0 } : { left: rect.left, top: rect.top };
1528
+ const token = { armed: false, moved: false, base: base, start: { x: event.clientX, y: event.clientY } };
1529
+ pressRef.current = token;
1530
+ try { if (event.currentTarget.setPointerCapture) event.currentTarget.setPointerCapture(event.pointerId); } catch (e) {}
1531
+ try { if (event.currentTarget.blur) event.currentTarget.blur(); } catch (e) {}
1532
+ }
1533
+
1534
+ function moveMove(event) {
1535
+ const token = pressRef.current;
1536
+ if (token === null) return;
1537
+ if (!token.armed) {
1538
+ const travelled = Math.abs(event.clientX - token.start.x) + Math.abs(event.clientY - token.start.y);
1539
+ if (travelled <= 6) return;
1540
+ armMove(token);
1541
+ }
1542
+ event.preventDefault();
1543
+ token.moved = true;
1544
+ const width = panelRef.current ? panelRef.current.offsetWidth : 380;
1545
+ const height = panelRef.current ? panelRef.current.offsetHeight : 120;
1546
+ const left = Math.min(Math.max(token.base.left + (event.clientX - token.start.x), 4), Math.max(4, window.innerWidth - width - 4));
1547
+ const top = Math.min(Math.max(token.base.top + (event.clientY - token.start.y), 4), Math.max(4, window.innerHeight - height - 4));
1548
+ setPanelPos({ left: left, top: top });
1549
+ }
1550
+
1551
+ function moveUp() {
1552
+ const token = pressRef.current;
1553
+ if (token === null) return;
1554
+ if (token.armed) {
1555
+ if (panelRef.current) {
1556
+ const rect = panelRef.current.getBoundingClientRect();
1557
+ writePanelPos({ left: Math.round(rect.left), top: Math.round(rect.top) });
1558
+ }
1559
+ /* The release after a move also emits a click; swallow exactly that one so
1560
+ dragging the panel never collapses it. */
1561
+ suppressClick.current = token.moved === true;
1562
+ } else {
1563
+ /* No travel and no hold: a plain click toggles. */
1564
+ setPanelOpen(!panelOpen);
1565
+ }
1566
+ setMoving(false);
1567
+ pressRef.current = null;
1568
+ }
1569
+
1570
+
1571
+
1572
+ /* The whole header row is the collapse control (no separate arrow): hover,
1573
+ pointer cursor and keyboard activation all come from this one element. */
1574
+ const head = h("div", {
1575
+ role: "button",
1576
+ tabIndex: 0,
1577
+ className: cx(C !== null ? C.cell : undefined, "dsh-mo-head", "dsh-mo-panelhead", moving ? "dsh-mo-moving" : undefined),
1578
+ style: C !== null ? S.panelHeadCell : S.boxHead,
1579
+ title: panelOpen ? t("providers.collapse") : t("providers.expand"),
1580
+ onPointerDown: moveDown,
1581
+ onPointerMove: moveMove,
1582
+ onPointerUp: moveUp,
1583
+ onPointerCancel: moveUp,
1584
+ onClick: function (event) {
1585
+ if (suppressClick.current) { suppressClick.current = false; return; }
1586
+ if (event && event.stopPropagation) event.stopPropagation();
1587
+ },
1588
+ onKeyDown: function (event) {
1589
+ if (event.key !== "Enter" && event.key !== " ") return;
1590
+ event.preventDefault();
1591
+ setPanelOpen(!panelOpen);
1592
+ }
1593
+ }, headChildren);
1594
+
1595
+
1596
+ const list = h(DragList, {
1597
+ ids: ids,
1598
+ disabled: false,
1599
+ onCommit: commit,
1600
+ render: function (id, index, dragProps) {
1601
+ const profile = display[id] || {};
1602
+ const count = (profile.models || []).length;
1603
+ const isOpen = openMap[id] === true;
1604
+ const rowChildren = [
1605
+
1606
+ h("span", { key: "n", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.rowName : undefined }, profile.displayName || id),
1607
+ h("span", { key: "c", className: C !== null ? C.cellValue : undefined, style: C === null ? S.rowMeta : undefined }, count + " " + t("composer.count"))
1608
+ ];
1609
+ const rowChevron = icon("IconChevronRightOutline14", {
1610
+ key: "v",
1611
+ className: C !== null ? cx(C.cellChevron, "dsh-mo-chev") : "dsh-mo-chev",
1612
+ style: C === null ? S.groupChevron : undefined
1613
+ });
1614
+ if (rowChevron !== null) rowChildren.push(rowChevron);
1615
+
1616
+ const row = h("div", {
1617
+ key: "row",
1618
+ draggable: dragProps.draggable,
1619
+ "data-mo": "provider-row",
1620
+ "data-mo-id": id,
1621
+ tabIndex: -1,
1622
+ role: "button",
1623
+ "aria-expanded": isOpen,
1624
+ onDragStart: dragProps.onDragStart,
1625
+ onDragOver: dragProps.onDragOver,
1626
+ onDragEnd: dragProps.onDragEnd,
1627
+ onDrop: dragProps.onDrop,
1628
+ onClick: function () {
1629
+ const next = {};
1630
+ for (const key in openMap) next[key] = openMap[key];
1631
+ next[id] = !(next[id] === true);
1632
+ setOpenMap(next);
1633
+ },
1634
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
1635
+ style: dragProps.dragging
1636
+ ? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
1637
+ : (C === null ? S.row : undefined)
1638
+ }, rowChildren);
1639
+
1640
+ if (!isOpen || count < 1) return h("div", { key: id, style: S.providerGroup }, row);
1641
+ return h("div", { key: id, style: S.providerGroup }, row,
1642
+ h(ProviderModelOrder, {
1643
+ key: "models",
1644
+ provider: { provider: id, settingsPath: ["providers", id], settingsNs: namespace },
1645
+ store: store,
1646
+ namespace: namespace,
1647
+ mutate: mutate,
1648
+ t: t,
1649
+ embedded: true
1650
+ })
1651
+ );
1652
+ }
1653
+ });
1654
+
1655
+ /* The resting place is the bottom-right corner; pinning its measured top-left
1656
+ once keeps that spot while making the panel top-anchored, so collapsing
1657
+ raises the bottom edge instead of dragging the header down. */
1658
+ const panelStyle = panelPos === null
1659
+ ? S.floatPanel
1660
+ : Object.assign({}, S.floatPanel, { left: panelPos.left, top: panelPos.top, right: "auto", bottom: "auto" });
1661
+ /* Original layout (header first, list below) with a TOP-anchored panel: the
1662
+ header never moves and collapsing pulls the bottom edge up. */
1663
+ /* Portalled to <body>: the footer seat lives deep inside the settings page, so a
1664
+ fixed panel there is trapped in that subtree's stacking context and any other
1665
+ plugin's floating widget paints over it. At body level the z-index is global. */
1666
+ /* The hidden in-place marker rides the seat (right after the card list),
1667
+ giving the mirror a stable anchor into the settings DOM. */
1668
+ return h(React.Fragment, null,
1669
+ h("div", { ref: seatRef, hidden: true, "data-mo": "seat-marker" }),
1670
+ ReactDOM.createPortal(
1671
+ h("div", { ref: panelRef, style: panelStyle, "data-mo": "panel" },
1672
+ head,
1673
+ panelOpen ? h("div", { style: S.floatBody }, list) : null
1674
+ ),
1675
+ document.body
1676
+ )
1677
+ );
1678
+ }
1679
+
1680
+ /* -------------------------------- helpers --------------------------------- */
1681
+
1682
+ function namespaceView(view, namespace) {
1683
+ if (!view || !view.namespaces) return null;
1684
+ for (let i = 0; i < view.namespaces.length; i++) {
1685
+ if (view.namespaces[i].ns === namespace) return view.namespaces[i];
1686
+ }
1687
+ return null;
1688
+ }
1689
+
1690
+ /* The user layer carries the values to write back; the resolved value is the
1691
+ fallback for providers configured only by a lower layer. */
1692
+ function providerLayer(nsView) {
1693
+ if (!nsView) return null;
1694
+ return (nsView.user && nsView.user.providers) || (nsView.value && nsView.value.providers) || null;
1695
+ }
1696
+
1697
+ /* The resolved value keeps the document's real key order; the user section is
1698
+ re-projected by the host and loses it, so ordering reads from `value`. */
1699
+ function orderLayer(nsView) {
1700
+ if (!nsView) return null;
1701
+ return (nsView.value && nsView.value.providers) || (nsView.user && nsView.user.providers) || null;
1702
+ }
1703
+
1704
+ /* -------------------------------- plugin body ----------------------------- */
1705
+ /* The seat contract resolves its standard props (sessionId, useSession, …) through
1706
+ THIS plugin's own context, so every service those props read must be declared
1707
+ here — otherwise the entry throws during render and is silently abdicated. */
1708
+ const inject = ["locale", "slots", "sessions", "remote", "remote.session", "remote.settings"];
1709
+
1710
+ /* Seat registration is isolated per seat: a future seat rename, a contract change,
1711
+ or another plugin taking the same single/keyed cell must not take the whole
1712
+ plugin down with it (a swallowed error here would also hide the cause, so it
1713
+ is reported through the client logger when one is available). */
1714
+ function guard(ctx, what, fn) {
1715
+ try {
1716
+ return fn();
1717
+ } catch (error) {
1718
+ const message = "dsh-model-organizer: could not register " + what + " — " + String((error && error.message) || error);
1719
+ try {
1720
+ if (ctx.logger && typeof ctx.logger.warn === "function") ctx.logger.warn(message);
1721
+ else console.warn(message);
1722
+ } catch (e) {}
1723
+ return function () {};
1724
+ }
1725
+ }
1726
+
1727
+ function apply(ctx) {
1728
+ try { window.__dshModelOrganizerBuild = BUILD; } catch (e) {}
1729
+ /* A DOM marker proving this bundle actually applied, independent of any seat.
1730
+ Seat registration is silent by design (a throw abdicates the entry), so
1731
+ "is the new code even loaded?" is otherwise unanswerable from the page. */
1732
+ try {
1733
+ if (document.body !== null && document.querySelector('[data-mo="boot"]') === null) {
1734
+ const boot = document.createElement("div");
1735
+ boot.setAttribute("data-mo", "boot");
1736
+ boot.setAttribute("data-mo-build", BUILD);
1737
+ boot.hidden = true;
1738
+ document.body.appendChild(boot);
1739
+ }
1740
+ } catch (e) {}
1741
+ ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-model-organizer: dictionaries");
1742
+ const t = ctx.locale.bind(NS);
1743
+
1744
+ ctx.inject(["slots", "modelDirectories", "sessions"], function (scope) {
1745
+ const models = scope.modelDirectories;
1746
+ const sessions = scope.sessions;
1747
+ scope.slots.inject("conversation.input.model", function () {
1748
+ return guard(scope, "conversation.input.model", function () { return scope.slots.register({
1749
+ name: "conversation.input.model",
1750
+ locale: NS,
1751
+ priority: -1,
1752
+ inject: function () {
1753
+ const first = arguments.length > 0 ? arguments[0] : undefined;
1754
+ const id = first && typeof first === "object" ? first.sessionId : first;
1755
+ const directory = models.directoryFor(id);
1756
+ const available = sessions.subagentAddress(id) === undefined;
1757
+ return {
1758
+ available: available,
1759
+ directory: directory.store,
1760
+ load: function () { if (available) directory.load().catch(function () {}); },
1761
+ select: function (selection) {
1762
+ if (!available) return Promise.resolve(false);
1763
+ /* Failures resolve WITH their error (the directory folds remote
1764
+ rejections into the result) so the menu can surface them —
1765
+ the shipped menu toasts exactly this. */
1766
+ return directory.select(selection).then(function (result) {
1767
+ if (result && result.ok) return true;
1768
+ return result && result.error ? result.error : false;
1769
+ }, function (err) { return err; });
1770
+ },
1771
+ t: t
1772
+ };
1773
+ }
1774
+ }, ProviderGroupedModelSelectGuarded); });
1775
+ });
1776
+ });
1777
+
1778
+ /* The settings mirror moved homes: DSH 0.1.7 exposes it as `configForms`
1779
+ (provided by ui-settings); 0.1.5/0.1.6 exposed the same object as
1780
+ `settingsScope`. A cordis `ctx.inject` whose service never appears simply
1781
+ never runs, so injecting only the old name made this whole seat vanish
1782
+ without a single error once DSH renamed it. Inject both names and mount
1783
+ once — whichever resolves first wins, the other stays dormant. */
1784
+ let orderPanelMounted = false;
1785
+
1786
+ function mountOrderPanel(scope, mirror) {
1787
+ if (orderPanelMounted) return;
1788
+ orderPanelMounted = true;
1789
+ /* Mark that the service resolved and the panel is being mounted — this
1790
+ separates "the seat never rendered" from "the inject never fired". */
1791
+ try {
1792
+ if (document.body !== null && document.querySelector('[data-mo="panel-mounted"]') === null) {
1793
+ const mark = document.createElement("div");
1794
+ mark.setAttribute("data-mo", "panel-mounted");
1795
+ mark.hidden = true;
1796
+ document.body.appendChild(mark);
1797
+ }
1798
+ } catch (e) {}
1799
+ mirror.ensure();
1800
+ const mutate = function (ops, revision) { return scope.remote.settings.mutate(PI_AI_NS, ops, revision); };
1801
+
1802
+ const face = function () {
1803
+ return { store: mirror, namespace: PI_AI_NS, mutate: mutate, t: t };
1804
+ };
1805
+
1806
+ /* NOTE: `settings.models.provider-card` is deliberately NOT taken. The owner
1807
+ dispatches it keyed by the provider's settings namespace, and
1808
+ @linxin666/dsh-client-ui-model-capabilities already registers that exact
1809
+ key; a keyed cell holds ONE occupant, so registering here throws and
1810
+ silently suppresses their "模型能力" panel. This plugin stays in the
1811
+ footer seat it owns instead. */
1812
+
1813
+ scope.slots.inject("settings.models.footer", function () {
1814
+ return guard(scope, "settings.models.footer", function () {
1815
+ return scope.slots.register({
1816
+ name: "settings.models.footer",
1817
+ id: "model-organizer-provider-order",
1818
+ order: 100,
1819
+ inject: face
1820
+ }, ProviderOrderPanelGuarded);
1821
+ });
1822
+ });
1823
+ }
1824
+
1825
+ ctx.inject(["slots", "configForms", "remote", "remote.settings"], function (scope) {
1826
+ mountOrderPanel(scope, scope.configForms.describe());
1827
+ });
1828
+ ctx.inject(["slots", "settingsScope", "remote", "remote.settings"], function (scope) {
1829
+ mountOrderPanel(scope, scope.settingsScope.describe());
1830
+ });
1831
+ }
1832
+
1833
+ module.exports = { name: "model-organizer", apply: apply, inject: inject };
1834
+ return module.exports;
1835
+ }
1836
+ });