@lynn123411/dsh-model-submenu 0.3.5 → 0.3.11

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.
@@ -0,0 +1,397 @@
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
+ /* The track is a capsule and the knob a larger circle riding on it: the knob
45
+ * overhangs at both ends, so it reads as a knob rather than as the track's own
46
+ * end cap. */
47
+ .sliderTrack {
48
+ position: relative;
49
+ width: 100%;
50
+ height: 16px;
51
+ border-radius: 999px;
52
+ corner-shape: round;
53
+ background: var(--dsw-alias-border-l3);
54
+ }
55
+
56
+ /* The fill is the track's own shape cut at the knob: its right cap carries the
57
+ * track's radius and ends under the knob, so the color runs into the knob
58
+ * instead of stopping at a straight edge, and the last detent reaches the
59
+ * track's end. */
60
+ .sliderFill {
61
+ position: absolute;
62
+ top: 0;
63
+ bottom: 0;
64
+ left: 0;
65
+ border-radius: 999px;
66
+ corner-shape: round;
67
+ pointer-events: none;
68
+ /* A level change eases the color to its new width; the drag turns this off so
69
+ * the edge stays under the pointer. */
70
+ transition: width 160ms cubic-bezier(0.2, 0, 0, 1);
71
+ }
72
+
73
+ /* One detent per level: the dot is the only thing that says the levels are
74
+ * discrete, so it stays visible on both sides of the knob. */
75
+ .sliderTick {
76
+ position: absolute;
77
+ top: 50%;
78
+ width: 4px;
79
+ height: 4px;
80
+ margin: -2px 0 0 -2px;
81
+ border-radius: 50%;
82
+ corner-shape: round;
83
+ background: var(--dsw-alias-label-tertiary);
84
+ pointer-events: none;
85
+ }
86
+
87
+ /* On the filled part the detent goes dark: white would read as one more grain
88
+ * of stardust, and the dot has to stay the thing that says "levels". */
89
+ .sliderTick[data-on] {
90
+ background: rgba(0, 0, 0, 0.28);
91
+ }
92
+
93
+ .sliderKnob {
94
+ position: absolute;
95
+ top: 50%;
96
+ /* The provider default draws a 1.5px ring; border-box keeps that ring inside
97
+ * the 20px circle the travel is computed for. */
98
+ box-sizing: border-box;
99
+ width: 20px;
100
+ height: 20px;
101
+ margin: -10px 0 0 -10px;
102
+ border-radius: 50%;
103
+ corner-shape: round;
104
+ background: var(--dsw-alias-label-primary-foreground);
105
+ box-shadow: var(--dsw-elevation-soft);
106
+ pointer-events: none;
107
+ /* Letting go eases the knob onto the detent it settled on; the drag turns this
108
+ * off so the knob sits exactly under the pointer. */
109
+ transition: left 160ms cubic-bezier(0.2, 0, 0, 1);
110
+ }
111
+
112
+ .slider[data-dragging] .sliderKnob,
113
+ .slider[data-dragging] .sliderFill {
114
+ transition: none;
115
+ }
116
+
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .sliderKnob,
119
+ .sliderFill {
120
+ transition: none;
121
+ }
122
+ }
123
+
124
+ /* Provider default: nothing is filled and the knob is a hollow ring, so the
125
+ * state never reads as the weakest level. */
126
+ .slider[data-default] .sliderKnob {
127
+ background: var(--dsw-alias-bg-layer-1);
128
+ border: 1.5px solid var(--dsw-alias-label-tertiary);
129
+ box-shadow: none;
130
+ }
131
+
132
+ .slider[data-busy] .sliderKnob {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ .slider:focus-visible .sliderTrack {
137
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
138
+ outline-offset: 3px;
139
+ }
140
+
141
+ /* ── the picker rows ─────────────────────────────────────────────────────── */
142
+
143
+ .rowBadge {
144
+ flex: none;
145
+ margin-inline-start: 6px;
146
+ }
147
+
148
+ .row {
149
+ align-items: center;
150
+ gap: 2px;
151
+ display: flex;
152
+ }
153
+
154
+ .rowPick {
155
+ box-sizing: border-box;
156
+ /* A slightly tighter side padding buys the name a few more characters, which
157
+ * is what keeps a long provider suffix readable inside a narrow card. */
158
+ padding-inline: 6px;
159
+ /* The scroll list is a block container, so a flex item's `flex: 1` does
160
+ * nothing here: the row must claim the full width or a long model name pushes
161
+ * the favorite mark and the check past the card's edge, where overflow clips
162
+ * them. */
163
+ width: 100%;
164
+ flex: 1;
165
+ min-width: 0;
166
+ min-height: 34px;
167
+ color: inherit;
168
+ text-align: left;
169
+ cursor: pointer;
170
+ background: 0 0;
171
+ border: none;
172
+ border-radius: var(--dsw-radius-md);
173
+ outline: none;
174
+ align-items: center;
175
+ gap: 4px;
176
+ padding: 5px 7px;
177
+ display: flex;
178
+ }
179
+
180
+ .rowPick:hover:not(:disabled),
181
+ .rowPick:focus-visible {
182
+ background: var(--dsw-alias-interactive-bg-hover);
183
+ }
184
+
185
+ .rowPick:disabled {
186
+ color: var(--dsw-alias-label-dimmed);
187
+ cursor: default;
188
+ }
189
+
190
+ .rowCopy {
191
+ flex-direction: column;
192
+ flex: 1;
193
+ min-width: 0;
194
+ display: flex;
195
+ }
196
+
197
+ .rowName {
198
+ color: inherit;
199
+ text-overflow: ellipsis;
200
+ white-space: nowrap;
201
+ font-size: 11.5px;
202
+ font-weight: 400;
203
+ line-height: 17px;
204
+ overflow: hidden;
205
+ }
206
+
207
+ .rowMeta {
208
+ color: var(--dsw-alias-label-tertiary);
209
+ text-overflow: ellipsis;
210
+ white-space: nowrap;
211
+ font-size: 12px;
212
+ line-height: 18px;
213
+ overflow: hidden;
214
+ }
215
+
216
+ .check {
217
+ color: var(--dsw-alias-label-primary);
218
+ flex: 0 0 15px;
219
+ place-items: center;
220
+ display: grid;
221
+ }
222
+
223
+ /* The picker's star is a real control: outline until hover or focus, solid and
224
+ * gold once favorited, and visible without hover on touch screens. */
225
+ .starButton {
226
+ color: var(--dsw-alias-label-tertiary);
227
+ cursor: pointer;
228
+ background: 0 0;
229
+ border: none;
230
+ border-radius: var(--dsw-radius-sm);
231
+ outline: none;
232
+ flex: none;
233
+ place-items: center;
234
+ width: 26px;
235
+ height: 26px;
236
+ display: grid;
237
+ opacity: 0;
238
+ }
239
+
240
+ .starButton:focus-visible {
241
+ outline: 2px solid var(--dsw-alias-border-l3);
242
+ }
243
+
244
+ .row:hover .starButton,
245
+ .row:focus-within .starButton {
246
+ opacity: 1;
247
+ }
248
+
249
+ .starButton[aria-pressed='true'] {
250
+ color: var(--dsw-alias-state-warn-primary);
251
+ opacity: 1;
252
+ }
253
+
254
+ @media (hover: none) {
255
+ .starButton {
256
+ opacity: 1;
257
+ }
258
+ }
259
+
260
+ .star {
261
+ flex: none;
262
+ }
263
+
264
+ /* ── shared status lines ─────────────────────────────────────────────────── */
265
+
266
+ .notice {
267
+ color: var(--dsw-alias-label-tertiary);
268
+ justify-content: space-between;
269
+ align-items: flex-start;
270
+ gap: 8px;
271
+ padding: 6px 10px;
272
+ font-size: 12px;
273
+ line-height: 18px;
274
+ display: flex;
275
+ }
276
+
277
+ .retry {
278
+ color: inherit;
279
+ font: inherit;
280
+ cursor: pointer;
281
+ background: 0 0;
282
+ border: none;
283
+ flex: none;
284
+ padding: 0;
285
+ font-weight: 600;
286
+ text-decoration: underline;
287
+ }
288
+
289
+ /* ── the picker dialog ───────────────────────────────────────────────────── */
290
+
291
+ .pickerDialog {
292
+ width: min(680px, 100%);
293
+ height: min(640px, 80vh);
294
+ gap: 0;
295
+ padding: 0;
296
+ }
297
+
298
+ .pickerContent {
299
+ min-height: 0;
300
+ flex: 1;
301
+ display: flex;
302
+ flex-direction: column;
303
+ }
304
+
305
+ /* Modal wraps children in a body row of its own; that row — the content
306
+ * region's last child — is what has to carry the column and give the list its
307
+ * remaining height, so the footer stays put while the list scrolls. */
308
+ .pickerContent > :last-child {
309
+ min-height: 0;
310
+ flex: 1;
311
+ display: flex;
312
+ flex-direction: column;
313
+ }
314
+
315
+ .pickerSearch {
316
+ padding: 0 20px 12px;
317
+ }
318
+
319
+ .pickerSearchInput {
320
+ width: 100%;
321
+ }
322
+
323
+ /* The tabs scroll sideways rather than wrap: the dialog keeps one row of
324
+ * providers however many the catalog has, so the list below starts at the same
325
+ * height every time. The bar keeps its hairline and hides its scrollbar. */
326
+ .pickerTabs {
327
+ border-bottom: .5px solid var(--dsw-alias-border-l2);
328
+ gap: 2px;
329
+ padding: 0 16px;
330
+ display: flex;
331
+ flex-wrap: nowrap;
332
+ overflow-x: auto;
333
+ overscroll-behavior-x: contain;
334
+ scrollbar-width: none;
335
+ }
336
+
337
+ .pickerTabs::-webkit-scrollbar {
338
+ display: none;
339
+ }
340
+
341
+ .pickerTab {
342
+ color: var(--dsw-alias-label-secondary);
343
+ cursor: pointer;
344
+ background: 0 0;
345
+ border: none;
346
+ border-radius: var(--dsw-radius-sm);
347
+ outline: none;
348
+ flex: none;
349
+ padding: 6px 10px;
350
+ font-size: 13px;
351
+ line-height: 18px;
352
+ white-space: nowrap;
353
+ }
354
+
355
+ .pickerTab:hover {
356
+ color: var(--dsw-alias-label-primary);
357
+ background: var(--dsw-alias-interactive-bg-hover);
358
+ }
359
+
360
+ .pickerTab[aria-selected='true'] {
361
+ color: var(--dsw-alias-label-primary);
362
+ background: var(--dsw-alias-interactive-bg-hover);
363
+ font-weight: 600;
364
+ }
365
+
366
+ .pickerTab:focus-visible {
367
+ outline: 2px solid var(--dsw-alias-border-l3);
368
+ }
369
+
370
+ .pickerList {
371
+ min-height: 0;
372
+ flex: 1;
373
+ padding: 8px 12px;
374
+ overflow-y: auto;
375
+ }
376
+
377
+ .pickerFooter {
378
+ border-top: .5px solid var(--dsw-alias-border-l2);
379
+ color: var(--dsw-alias-label-tertiary);
380
+ align-items: center;
381
+ gap: 6px;
382
+ padding: 10px 20px;
383
+ font-size: 12px;
384
+ line-height: 18px;
385
+ display: flex;
386
+ }
387
+
388
+ .footerHint {
389
+ align-items: center;
390
+ gap: 6px;
391
+ display: flex;
392
+ flex-wrap: wrap;
393
+ }
394
+
395
+ .footerDot {
396
+ color: var(--dsw-alias-label-dimmed);
397
+ }
@@ -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 './ModelSubmenu.module.css'
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
+ }