@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.
@@ -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 './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
+ }