@lynn123411/dsh-model-submenu 0.3.5 → 0.3.10
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/DETAILS.md +60 -4
- package/README.md +12 -11
- package/lib/client.js +754 -631
- package/lib/client.js.map +1 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +18 -17
- package/src/client/EffortSlider.tsx +159 -0
- package/src/client/EffortTint.tsx +61 -0
- package/src/client/ModelMenuBridge.tsx +203 -0
- package/src/client/ModelOptions.module.css +372 -0
- package/src/client/ModelPicker.tsx +10 -4
- package/src/client/ModelPickerHost.tsx +146 -0
- package/src/client/ModelRow.tsx +55 -96
- package/src/client/index.ts +65 -28
- package/src/client/level-color.ts +51 -0
- package/src/client/locales.ts +12 -33
- package/src/client/picker-state.ts +55 -0
- package/src/client/selection.ts +43 -17
- package/src/client/slider.ts +51 -0
- package/src/client/ModelSubmenu.module.css +0 -509
- package/src/client/ModelSubmenu.tsx +0 -429
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The reasoning-level slider, the picker, and the composer overlay that hosts
|
|
3
|
+
* the picker. The dialog renders through the primitives' Modal, so this file
|
|
4
|
+
* paints no floating fill — it only places content on the surface that
|
|
5
|
+
* component owns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* The overlay entry is only a mount point for the dialog and its toast; it must
|
|
9
|
+
* not take space inside the composer card. */
|
|
10
|
+
.pickerHost {
|
|
11
|
+
display: contents;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── the reasoning-level slider ──────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
/* The Host's effort cell is a flex row: label, value, chevron. The slider takes
|
|
17
|
+
* the slack between the label and the value, so the cell keeps the Host's own
|
|
18
|
+
* metrics and the menu does not grow. */
|
|
19
|
+
.sliderCell {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex: 1 1 auto;
|
|
22
|
+
align-items: center;
|
|
23
|
+
align-self: stretch;
|
|
24
|
+
min-width: 96px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.slider {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex: 1;
|
|
30
|
+
align-items: center;
|
|
31
|
+
height: 100%;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
outline: none;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
touch-action: none;
|
|
36
|
+
user-select: none;
|
|
37
|
+
-webkit-user-select: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.slider[data-busy] {
|
|
41
|
+
cursor: default;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.sliderTrack {
|
|
45
|
+
position: relative;
|
|
46
|
+
width: 100%;
|
|
47
|
+
height: 20px;
|
|
48
|
+
border-radius: 999px;
|
|
49
|
+
corner-shape: round;
|
|
50
|
+
background: var(--dsw-alias-border-l3);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* The fill takes the level's color and is clipped to the filled part, so the
|
|
54
|
+
* color under the knob is the color the level's text takes. */
|
|
55
|
+
.sliderFill {
|
|
56
|
+
position: absolute;
|
|
57
|
+
top: 0;
|
|
58
|
+
bottom: 0;
|
|
59
|
+
left: 0;
|
|
60
|
+
border-radius: 999px;
|
|
61
|
+
corner-shape: round;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* One detent per level: the dot is the only thing that says the levels are
|
|
67
|
+
* discrete, so it stays visible on both sides of the knob. */
|
|
68
|
+
.sliderTick {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: 50%;
|
|
71
|
+
width: 4px;
|
|
72
|
+
height: 4px;
|
|
73
|
+
margin: -2px 0 0 -2px;
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
corner-shape: round;
|
|
76
|
+
background: var(--dsw-alias-label-tertiary);
|
|
77
|
+
pointer-events: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* On the filled part the detent goes dark: white would read as one more grain
|
|
81
|
+
* of stardust, and the dot has to stay the thing that says "levels". */
|
|
82
|
+
.sliderTick[data-on] {
|
|
83
|
+
background: rgba(0, 0, 0, 0.28);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sliderKnob {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 50%;
|
|
89
|
+
width: 20px;
|
|
90
|
+
height: 20px;
|
|
91
|
+
margin: -10px 0 0 -10px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
corner-shape: round;
|
|
94
|
+
background: var(--dsw-alias-label-primary-foreground);
|
|
95
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
96
|
+
pointer-events: none;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Provider default: nothing is filled and the knob is a hollow ring, so the
|
|
100
|
+
* state never reads as the weakest level. */
|
|
101
|
+
.slider[data-default] .sliderKnob {
|
|
102
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
103
|
+
border: 1.5px solid var(--dsw-alias-label-tertiary);
|
|
104
|
+
box-shadow: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.slider[data-busy] .sliderKnob {
|
|
108
|
+
opacity: 0.6;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.slider:focus-visible .sliderTrack {
|
|
112
|
+
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
113
|
+
outline-offset: 3px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ── the picker rows ─────────────────────────────────────────────────────── */
|
|
117
|
+
|
|
118
|
+
.rowBadge {
|
|
119
|
+
flex: none;
|
|
120
|
+
margin-inline-start: 6px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.row {
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 2px;
|
|
126
|
+
display: flex;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.rowPick {
|
|
130
|
+
box-sizing: border-box;
|
|
131
|
+
/* A slightly tighter side padding buys the name a few more characters, which
|
|
132
|
+
* is what keeps a long provider suffix readable inside a narrow card. */
|
|
133
|
+
padding-inline: 6px;
|
|
134
|
+
/* The scroll list is a block container, so a flex item's `flex: 1` does
|
|
135
|
+
* nothing here: the row must claim the full width or a long model name pushes
|
|
136
|
+
* the favorite mark and the check past the card's edge, where overflow clips
|
|
137
|
+
* them. */
|
|
138
|
+
width: 100%;
|
|
139
|
+
flex: 1;
|
|
140
|
+
min-width: 0;
|
|
141
|
+
min-height: 34px;
|
|
142
|
+
color: inherit;
|
|
143
|
+
text-align: left;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
background: 0 0;
|
|
146
|
+
border: none;
|
|
147
|
+
border-radius: var(--dsw-radius-md);
|
|
148
|
+
outline: none;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 4px;
|
|
151
|
+
padding: 5px 7px;
|
|
152
|
+
display: flex;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.rowPick:hover:not(:disabled),
|
|
156
|
+
.rowPick:focus-visible {
|
|
157
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.rowPick:disabled {
|
|
161
|
+
color: var(--dsw-alias-label-dimmed);
|
|
162
|
+
cursor: default;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.rowCopy {
|
|
166
|
+
flex-direction: column;
|
|
167
|
+
flex: 1;
|
|
168
|
+
min-width: 0;
|
|
169
|
+
display: flex;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.rowName {
|
|
173
|
+
color: inherit;
|
|
174
|
+
text-overflow: ellipsis;
|
|
175
|
+
white-space: nowrap;
|
|
176
|
+
font-size: 11.5px;
|
|
177
|
+
font-weight: 400;
|
|
178
|
+
line-height: 17px;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.rowMeta {
|
|
183
|
+
color: var(--dsw-alias-label-tertiary);
|
|
184
|
+
text-overflow: ellipsis;
|
|
185
|
+
white-space: nowrap;
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
line-height: 18px;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.check {
|
|
192
|
+
color: var(--dsw-alias-label-primary);
|
|
193
|
+
flex: 0 0 15px;
|
|
194
|
+
place-items: center;
|
|
195
|
+
display: grid;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* The picker's star is a real control: outline until hover or focus, solid and
|
|
199
|
+
* gold once favorited, and visible without hover on touch screens. */
|
|
200
|
+
.starButton {
|
|
201
|
+
color: var(--dsw-alias-label-tertiary);
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
background: 0 0;
|
|
204
|
+
border: none;
|
|
205
|
+
border-radius: var(--dsw-radius-sm);
|
|
206
|
+
outline: none;
|
|
207
|
+
flex: none;
|
|
208
|
+
place-items: center;
|
|
209
|
+
width: 26px;
|
|
210
|
+
height: 26px;
|
|
211
|
+
display: grid;
|
|
212
|
+
opacity: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.starButton:focus-visible {
|
|
216
|
+
outline: 2px solid var(--dsw-alias-border-l3);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.row:hover .starButton,
|
|
220
|
+
.row:focus-within .starButton {
|
|
221
|
+
opacity: 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.starButton[aria-pressed='true'] {
|
|
225
|
+
color: var(--dsw-alias-state-warn-primary);
|
|
226
|
+
opacity: 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@media (hover: none) {
|
|
230
|
+
.starButton {
|
|
231
|
+
opacity: 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.star {
|
|
236
|
+
flex: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ── shared status lines ─────────────────────────────────────────────────── */
|
|
240
|
+
|
|
241
|
+
.notice {
|
|
242
|
+
color: var(--dsw-alias-label-tertiary);
|
|
243
|
+
justify-content: space-between;
|
|
244
|
+
align-items: flex-start;
|
|
245
|
+
gap: 8px;
|
|
246
|
+
padding: 6px 10px;
|
|
247
|
+
font-size: 12px;
|
|
248
|
+
line-height: 18px;
|
|
249
|
+
display: flex;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.retry {
|
|
253
|
+
color: inherit;
|
|
254
|
+
font: inherit;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
background: 0 0;
|
|
257
|
+
border: none;
|
|
258
|
+
flex: none;
|
|
259
|
+
padding: 0;
|
|
260
|
+
font-weight: 600;
|
|
261
|
+
text-decoration: underline;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ── the picker dialog ───────────────────────────────────────────────────── */
|
|
265
|
+
|
|
266
|
+
.pickerDialog {
|
|
267
|
+
width: min(680px, 100%);
|
|
268
|
+
height: min(640px, 80vh);
|
|
269
|
+
gap: 0;
|
|
270
|
+
padding: 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.pickerContent {
|
|
274
|
+
min-height: 0;
|
|
275
|
+
flex: 1;
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-direction: column;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Modal wraps children in a body row of its own; that row — the content
|
|
281
|
+
* region's last child — is what has to carry the column and give the list its
|
|
282
|
+
* remaining height, so the footer stays put while the list scrolls. */
|
|
283
|
+
.pickerContent > :last-child {
|
|
284
|
+
min-height: 0;
|
|
285
|
+
flex: 1;
|
|
286
|
+
display: flex;
|
|
287
|
+
flex-direction: column;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.pickerSearch {
|
|
291
|
+
padding: 0 20px 12px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.pickerSearchInput {
|
|
295
|
+
width: 100%;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* The tabs scroll sideways rather than wrap: the dialog keeps one row of
|
|
299
|
+
* providers however many the catalog has, so the list below starts at the same
|
|
300
|
+
* height every time. The bar keeps its hairline and hides its scrollbar. */
|
|
301
|
+
.pickerTabs {
|
|
302
|
+
border-bottom: .5px solid var(--dsw-alias-border-l2);
|
|
303
|
+
gap: 2px;
|
|
304
|
+
padding: 0 16px;
|
|
305
|
+
display: flex;
|
|
306
|
+
flex-wrap: nowrap;
|
|
307
|
+
overflow-x: auto;
|
|
308
|
+
overscroll-behavior-x: contain;
|
|
309
|
+
scrollbar-width: none;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.pickerTabs::-webkit-scrollbar {
|
|
313
|
+
display: none;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.pickerTab {
|
|
317
|
+
color: var(--dsw-alias-label-secondary);
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
background: 0 0;
|
|
320
|
+
border: none;
|
|
321
|
+
border-radius: var(--dsw-radius-sm);
|
|
322
|
+
outline: none;
|
|
323
|
+
flex: none;
|
|
324
|
+
padding: 6px 10px;
|
|
325
|
+
font-size: 13px;
|
|
326
|
+
line-height: 18px;
|
|
327
|
+
white-space: nowrap;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.pickerTab:hover {
|
|
331
|
+
color: var(--dsw-alias-label-primary);
|
|
332
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.pickerTab[aria-selected='true'] {
|
|
336
|
+
color: var(--dsw-alias-label-primary);
|
|
337
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
338
|
+
font-weight: 600;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.pickerTab:focus-visible {
|
|
342
|
+
outline: 2px solid var(--dsw-alias-border-l3);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.pickerList {
|
|
346
|
+
min-height: 0;
|
|
347
|
+
flex: 1;
|
|
348
|
+
padding: 8px 12px;
|
|
349
|
+
overflow-y: auto;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.pickerFooter {
|
|
353
|
+
border-top: .5px solid var(--dsw-alias-border-l2);
|
|
354
|
+
color: var(--dsw-alias-label-tertiary);
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 6px;
|
|
357
|
+
padding: 10px 20px;
|
|
358
|
+
font-size: 12px;
|
|
359
|
+
line-height: 18px;
|
|
360
|
+
display: flex;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.footerHint {
|
|
364
|
+
align-items: center;
|
|
365
|
+
gap: 6px;
|
|
366
|
+
display: flex;
|
|
367
|
+
flex-wrap: wrap;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.footerDot {
|
|
371
|
+
color: var(--dsw-alias-label-dimmed);
|
|
372
|
+
}
|
|
@@ -37,7 +37,7 @@ import { Tag, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
37
37
|
import type { ModelFactsLookup } from './facts-source.ts'
|
|
38
38
|
import type { Selection } from './selection.ts'
|
|
39
39
|
import { planModelCommit } from './selection.ts'
|
|
40
|
-
import css from './
|
|
40
|
+
import css from './ModelOptions.module.css'
|
|
41
41
|
|
|
42
42
|
/** Props the seat hands the picker. */
|
|
43
43
|
export interface ModelPickerProps {
|
|
@@ -55,6 +55,13 @@ export interface ModelPickerProps {
|
|
|
55
55
|
readonly favorites: readonly { readonly provider: string; readonly model: string }[]
|
|
56
56
|
/** Whether a selection is in flight. */
|
|
57
57
|
readonly busy: boolean
|
|
58
|
+
/**
|
|
59
|
+
* The reasoning level this browser last used for one model, if any.
|
|
60
|
+
* @param provider - the provider route key.
|
|
61
|
+
* @param model - the model id inside that provider.
|
|
62
|
+
* @returns the remembered level, or undefined when none is remembered.
|
|
63
|
+
*/
|
|
64
|
+
readonly rememberedOf?: (provider: string, model: string) => string | undefined
|
|
58
65
|
/** Submit one selection. */
|
|
59
66
|
readonly onPick: (selection: Selection) => void
|
|
60
67
|
/** Toggle one model's favorite. */
|
|
@@ -71,7 +78,7 @@ export interface ModelPickerProps {
|
|
|
71
78
|
* @returns the dialog; nothing while closed.
|
|
72
79
|
*/
|
|
73
80
|
export function ModelPicker({
|
|
74
|
-
open, resolveText, state, live, facts, favorites, busy, onPick, onToggleFavorite, onReload, onClose, t,
|
|
81
|
+
open, resolveText, state, live, facts, favorites, busy, rememberedOf, onPick, onToggleFavorite, onReload, onClose, t,
|
|
75
82
|
}: ModelPickerProps & PropsLocale<'modelSubmenu'>) {
|
|
76
83
|
const id = useId()
|
|
77
84
|
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -137,7 +144,7 @@ export function ModelPicker({
|
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
const pick = (row: CatalogRow) => {
|
|
140
|
-
const selection = planModelCommit(live, row.provider, row.model, reason)
|
|
147
|
+
const selection = planModelCommit(live, row.provider, row.model, reason, rememberedOf?.(row.provider, row.model))
|
|
141
148
|
if (selection === null) {
|
|
142
149
|
onClose()
|
|
143
150
|
return
|
|
@@ -258,7 +265,6 @@ export function ModelPicker({
|
|
|
258
265
|
: <Tag tone={row.entry.badge.tone} className={css.rowBadge}>{resolveText(row.entry.badge.text)}</Tag>}
|
|
259
266
|
selected={live?.provider === row.provider && live.model === row.model}
|
|
260
267
|
busy={busy}
|
|
261
|
-
star="interactive"
|
|
262
268
|
favorited={isFavorite(favorites, row.provider, row.model)}
|
|
263
269
|
meta={metaLine({
|
|
264
270
|
row,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer overlay's picker view: the dialog, its catalog, and its verbs.
|
|
3
|
+
*
|
|
4
|
+
* It lives in `conversation.input.overlay` rather than beside the row, because
|
|
5
|
+
* the Host unmounts the model menu as soon as a row is clicked. Everything the
|
|
6
|
+
* dialog reads arrives through this entry's injected face.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
9
|
+
import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
10
|
+
import type { ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client';
|
|
11
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
|
|
12
|
+
import { ModelPicker } from './ModelPicker.tsx';
|
|
13
|
+
import type { FactsSource } from './facts-source.ts';
|
|
14
|
+
import {
|
|
15
|
+
readFavorites,
|
|
16
|
+
toggleFavorite,
|
|
17
|
+
type FavoriteModel,
|
|
18
|
+
type FavoritesStorage,
|
|
19
|
+
} from './favorites.ts';
|
|
20
|
+
import {
|
|
21
|
+
effortFor,
|
|
22
|
+
readEfforts,
|
|
23
|
+
rememberEffort,
|
|
24
|
+
type RememberedEffort,
|
|
25
|
+
} from './effort-memory.ts';
|
|
26
|
+
import { selectionOf, type Selection } from './selection.ts';
|
|
27
|
+
import type { PickerStore } from './picker-state.ts';
|
|
28
|
+
import css from './ModelOptions.module.css';
|
|
29
|
+
|
|
30
|
+
/** Props of the overlay view: the session, the store, and the seat's injected face. */
|
|
31
|
+
export type ModelPickerHostProps = PropsLocale<'modelSubmenu'>
|
|
32
|
+
& ModelSelectInjected
|
|
33
|
+
& {
|
|
34
|
+
/** Session whose catalog this view renders. */
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
/** The request written by the menu row. */
|
|
37
|
+
readonly picker: PickerStore;
|
|
38
|
+
/** The settings-derived metadata the picker's line reads. */
|
|
39
|
+
readonly factsSource: FactsSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render the picker for this session.
|
|
44
|
+
* @param props - session, store, catalog face, and localized copy.
|
|
45
|
+
* @returns the dialog while the store names this session; otherwise nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function ModelPickerHost({
|
|
48
|
+
sessionId, picker, available, directory, load, select, resolveText, factsSource, t,
|
|
49
|
+
}: ModelPickerHostProps) {
|
|
50
|
+
const request = useSyncExternalStore(picker.subscribe, picker.getSnapshot);
|
|
51
|
+
const state = useSyncExternalStore((listener) => directory.subscribe(listener), () => directory.getSnapshot());
|
|
52
|
+
const [favorites, setFavorites] = useState<readonly FavoriteModel[]>(() => readFavorites(storageOf()));
|
|
53
|
+
const [memory, setMemory] = useState<readonly RememberedEffort[]>(() => readEfforts(storageOf()));
|
|
54
|
+
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null);
|
|
55
|
+
const toastSeq = useRef(0);
|
|
56
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
|
|
58
|
+
const busy = state.status === 'selecting';
|
|
59
|
+
const live = useMemo(() => selectionOf(state), [state]);
|
|
60
|
+
const facts = useSyncExternalStore((listener) => factsSource.subscribe(listener), () => factsSource.getSnapshot());
|
|
61
|
+
const open = request.sessionId === sessionId;
|
|
62
|
+
// The injected face is cached per entry and session, but the read goes through
|
|
63
|
+
// a ref anyway: a catalog load writes the store, so an unstable `load` in the
|
|
64
|
+
// dependency list would reload on every render it caused.
|
|
65
|
+
const loadRef = useRef(load);
|
|
66
|
+
loadRef.current = load;
|
|
67
|
+
|
|
68
|
+
// Opening reads the catalog again: another client may have changed it, and a
|
|
69
|
+
// failed load has to be retryable from the same gesture.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (open) loadRef.current();
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
// The Host's own menu changes the reasoning level without passing through this
|
|
75
|
+
// plugin, so the memory is written from the directory snapshot too — a picker
|
|
76
|
+
// click alone would only ever record levels chosen in the dialog. The key guard
|
|
77
|
+
// keeps one write per distinct selection instead of one per snapshot.
|
|
78
|
+
const current = state.current;
|
|
79
|
+
const rememberedRef = useRef<string | null>(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (current === null) return;
|
|
82
|
+
const key = `${current.provider}/${current.model}/${current.reasoningEffort ?? ''}`;
|
|
83
|
+
if (rememberedRef.current === key) return;
|
|
84
|
+
rememberedRef.current = key;
|
|
85
|
+
setMemory(rememberEffort(storageOf(), current.provider, current.model, current.reasoningEffort));
|
|
86
|
+
}, [current]);
|
|
87
|
+
|
|
88
|
+
if (!available) return null;
|
|
89
|
+
|
|
90
|
+
const raiseToast = (text: string): void => {
|
|
91
|
+
toastSeq.current += 1;
|
|
92
|
+
setToast({ seq: toastSeq.current, text });
|
|
93
|
+
};
|
|
94
|
+
const submit = (selection: Selection): void => {
|
|
95
|
+
void select(selection).then((result) => {
|
|
96
|
+
if (result === undefined || result.ok) {
|
|
97
|
+
setMemory(rememberEffort(storageOf(), selection.provider, selection.model, selection.reasoningEffort));
|
|
98
|
+
picker.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
raiseToast(result.error.code === 'session/writer-held'
|
|
102
|
+
? t('error.sessionInUse')
|
|
103
|
+
: t('error.action', { message: `${result.error.code}: ${result.error.message}` }));
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return <div ref={rootRef} className={css.pickerHost}>
|
|
108
|
+
<ModelPicker
|
|
109
|
+
open={open}
|
|
110
|
+
resolveText={resolveText}
|
|
111
|
+
state={state}
|
|
112
|
+
live={live}
|
|
113
|
+
facts={facts}
|
|
114
|
+
favorites={favorites}
|
|
115
|
+
busy={busy}
|
|
116
|
+
t={t}
|
|
117
|
+
rememberedOf={(provider, model) => effortFor(memory, provider, model)}
|
|
118
|
+
onPick={submit}
|
|
119
|
+
onToggleFavorite={(provider, model) => {
|
|
120
|
+
setFavorites(toggleFavorite(storageOf(), provider, model));
|
|
121
|
+
}}
|
|
122
|
+
onReload={load}
|
|
123
|
+
onClose={() => {
|
|
124
|
+
picker.close();
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
{toast !== null && <Toast
|
|
128
|
+
key={toast.seq}
|
|
129
|
+
text={toast.text}
|
|
130
|
+
icon={<IconWarningOutlineRegular />}
|
|
131
|
+
anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
|
|
132
|
+
onDone={() => {
|
|
133
|
+
setToast(null);
|
|
134
|
+
}}
|
|
135
|
+
/>}
|
|
136
|
+
</div>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
|
|
140
|
+
function storageOf(): FavoritesStorage | undefined {
|
|
141
|
+
try {
|
|
142
|
+
return globalThis.localStorage;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|