@lynn123411/dsh-model-submenu 0.3.3 → 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 -9
- package/lib/client.js +759 -629
- package/lib/client.js.map +1 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +20 -19
- package/src/client/EffortSlider.tsx +159 -0
- package/src/client/EffortTint.tsx +61 -0
- package/src/client/ModelMenuBridge.tsx +203 -0
- package/src/client/{ModelSubmenu.module.css → ModelOptions.module.css} +100 -227
- package/src/client/ModelPicker.tsx +21 -5
- package/src/client/ModelPickerHost.tsx +146 -0
- package/src/client/ModelRow.tsx +57 -95
- package/src/client/index.ts +65 -27
- 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.tsx +0 -431
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lynn123411/dsh-model-submenu",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.10",
|
|
4
|
+
"description": "增强输入框的模型菜单:第一行直接打开跨供应商的模型弹窗,第二行的推理等级是可拖动的滑块,档位带配色。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"packageManager": "pnpm@11.7.0",
|
|
7
7
|
"publishConfig": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"engines": {
|
|
12
12
|
"node": "^22.19.0 || >=24.0.0",
|
|
13
|
-
"dsh": ">=0.2.
|
|
13
|
+
"dsh": ">=0.2.2-rc.2"
|
|
14
14
|
},
|
|
15
15
|
"main": "lib/index.js",
|
|
16
16
|
"exports": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"inject": [
|
|
49
49
|
"@deepseek-ai/dsh-api-remotes",
|
|
50
50
|
"@deepseek-ai/dsh-client-locale",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
51
52
|
"@deepseek-ai/dsh-client-ui-model-selection",
|
|
52
53
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
53
54
|
"@deepseek-ai/dsh-client-ui-slots"
|
|
@@ -60,11 +61,11 @@
|
|
|
60
61
|
"smoke": "node scripts/smoke-client.mjs",
|
|
61
62
|
"assert:lib-untracked": "node scripts/assert-lib-untracked.mjs",
|
|
62
63
|
"check": "pnpm typecheck && pnpm build && pnpm smoke && pnpm test && pnpm assert:lib-untracked",
|
|
63
|
-
"test": "node scripts/test-favorites.mjs && node scripts/test-facts.mjs && node scripts/test-commit.mjs && node scripts/test-rows.mjs && node scripts/test-effort-memory.mjs && node scripts/test-render.mjs"
|
|
64
|
+
"test": "node scripts/test-favorites.mjs && node scripts/test-facts.mjs && node scripts/test-commit.mjs && node scripts/test-rows.mjs && node scripts/test-effort-memory.mjs && node scripts/test-slider.mjs && node scripts/test-render.mjs"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
|
-
"@deepseek-ai/cordis": "^4.0
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.
|
|
67
|
+
"@deepseek-ai/cordis": "^4.1.0",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-rc.2",
|
|
68
69
|
"react": "^18.2.0"
|
|
69
70
|
},
|
|
70
71
|
"peerDependenciesMeta": {
|
|
@@ -73,19 +74,19 @@
|
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
76
|
-
"@deepseek-ai/cordis": "^4.0
|
|
77
|
-
"@deepseek-ai/dsh-api-remotes": ">=0.2.
|
|
78
|
-
"@deepseek-ai/dsh-api-session-controller": ">=0.2.
|
|
79
|
-
"@deepseek-ai/dsh-client-locale": ">=0.2.
|
|
80
|
-
"@deepseek-ai/dsh-client-store": ">=0.2.
|
|
81
|
-
"@deepseek-ai/dsh-client-ui-conversation": ">=0.2.
|
|
82
|
-
"@deepseek-ai/dsh-client-ui-model-selection": ">=0.2.
|
|
83
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.
|
|
84
|
-
"@deepseek-ai/dsh-client-ui-renderer": ">=0.2.
|
|
85
|
-
"@deepseek-ai/dsh-client-ui-settings": ">=0.2.
|
|
86
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.
|
|
87
|
-
"@deepseek-ai/dsh-session": ">=0.2.
|
|
88
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.2.
|
|
77
|
+
"@deepseek-ai/cordis": "^4.1.0",
|
|
78
|
+
"@deepseek-ai/dsh-api-remotes": ">=0.2.2-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-api-session-controller": ">=0.2.2-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-client-locale": ">=0.2.2-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-client-store": ">=0.2.2-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-conversation": ">=0.2.2-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-client-ui-model-selection": ">=0.2.2-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.2.2-rc.2",
|
|
86
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.2.2-rc.2",
|
|
87
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.2-rc.2",
|
|
88
|
+
"@deepseek-ai/dsh-session": ">=0.2.2-rc.2",
|
|
89
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.2.2-rc.2",
|
|
89
90
|
"@types/node": "^22.20.0",
|
|
90
91
|
"@types/react": "~18.3.1",
|
|
91
92
|
"@types/react-dom": "~18.3.0",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reasoning-level slider that replaces the Host's effort drill.
|
|
3
|
+
*
|
|
4
|
+
* The Host's second menu cell is a button that drills into a list of levels.
|
|
5
|
+
* This slider sits inside that same cell, between the Host's label and value, so
|
|
6
|
+
* the levels are reachable without leaving the menu. Levels are discrete: the
|
|
7
|
+
* knob snaps to one detent per level, the drag only moves the knob, and the
|
|
8
|
+
* release commits once. Keyboard arrows step and commit immediately.
|
|
9
|
+
*
|
|
10
|
+
* The fill takes the level's color, so the track and the two captions read as
|
|
11
|
+
* one signal: "off" is neutral, the levels above it walk a blue-to-violet ramp,
|
|
12
|
+
* and only the top level fills with the whole gradient.
|
|
13
|
+
*
|
|
14
|
+
* "Provider default" is not a detent: it is a state, drawn as an empty track
|
|
15
|
+
* with a hollow knob, so it never reads as the lowest level.
|
|
16
|
+
*/
|
|
17
|
+
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
|
|
18
|
+
import type { EffortChoice } from './selection.ts';
|
|
19
|
+
import { LEVEL_GRADIENT, isTopLevel, levelColor } from './level-color.ts';
|
|
20
|
+
import { knobOffset, offsetOf, stepAt } from './slider.ts';
|
|
21
|
+
import css from './ModelOptions.module.css';
|
|
22
|
+
|
|
23
|
+
/** Props of the slider: the levels, the one in force, and the commit path. */
|
|
24
|
+
export interface EffortSliderProps {
|
|
25
|
+
/** The levels in catalog order, lowest first. */
|
|
26
|
+
readonly steps: readonly EffortChoice[];
|
|
27
|
+
/** Index of the level in force, already clamped into `steps`. */
|
|
28
|
+
readonly current: number;
|
|
29
|
+
/** Whether the provider default is in force instead of a level. */
|
|
30
|
+
readonly defaultActive: boolean;
|
|
31
|
+
/** Where the knob rests while the provider default is in force. */
|
|
32
|
+
readonly defaultIndex: number;
|
|
33
|
+
/** Localized name of the provider default, read while it is in force. */
|
|
34
|
+
readonly defaultLabel: string;
|
|
35
|
+
/** Whether a selection is in flight; input is ignored while it is. */
|
|
36
|
+
readonly busy: boolean;
|
|
37
|
+
/** Accessible name, taken from the Host's own row label. */
|
|
38
|
+
readonly label: string;
|
|
39
|
+
/** @param index - the level the user settled on. */
|
|
40
|
+
readonly onCommit: (index: number) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render the slider.
|
|
45
|
+
* @param props - levels, the level in force, and the commit path.
|
|
46
|
+
* @returns one focusable slider over the discrete levels.
|
|
47
|
+
*/
|
|
48
|
+
export function EffortSlider({
|
|
49
|
+
steps, current, defaultActive, defaultIndex, defaultLabel, busy, label, onCommit,
|
|
50
|
+
}: EffortSliderProps) {
|
|
51
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
52
|
+
// The drag index lives in a ref as well as in state: a press and a release can
|
|
53
|
+
// arrive in one task, and the release must not read a render that never ran.
|
|
54
|
+
const dragRef = useRef<number | null>(null);
|
|
55
|
+
const [drag, setDrag] = useState<number | null>(null);
|
|
56
|
+
const resting = defaultActive ? defaultIndex : current;
|
|
57
|
+
const active = drag ?? resting;
|
|
58
|
+
const beginDrag = (index: number | null): void => {
|
|
59
|
+
dragRef.current = index;
|
|
60
|
+
setDrag(index);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const indexAt = (clientX: number): number => {
|
|
64
|
+
const track = trackRef.current;
|
|
65
|
+
if (track === null) return active;
|
|
66
|
+
const rect = track.getBoundingClientRect();
|
|
67
|
+
return stepAt(clientX, rect.left, rect.width, steps.length);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
|
|
71
|
+
if (busy) return;
|
|
72
|
+
// The Host's menu cell owns this subtree; without these the press would
|
|
73
|
+
// also settle the cell and pull focus out of the slider.
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
event.stopPropagation();
|
|
76
|
+
beginDrag(indexAt(event.clientX));
|
|
77
|
+
try {
|
|
78
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
79
|
+
} catch {
|
|
80
|
+
// A synthetic pointer has no capture to take; the drag still tracks moves.
|
|
81
|
+
}
|
|
82
|
+
event.currentTarget.focus();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
|
|
86
|
+
if (dragRef.current === null || busy) return;
|
|
87
|
+
const next = indexAt(event.clientX);
|
|
88
|
+
if (next !== dragRef.current) beginDrag(next);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const settle = (event: PointerEvent<HTMLDivElement>, commit: boolean): void => {
|
|
92
|
+
if (dragRef.current === null) return;
|
|
93
|
+
const next = indexAt(event.clientX);
|
|
94
|
+
beginDrag(null);
|
|
95
|
+
if (!commit) return;
|
|
96
|
+
// Leaving the provider default is a change even when the knob lands where
|
|
97
|
+
// the default already pointed.
|
|
98
|
+
if (next !== current || defaultActive) onCommit(next);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
102
|
+
if (busy) return;
|
|
103
|
+
const last = steps.length - 1;
|
|
104
|
+
const next = event.key === 'ArrowRight' || event.key === 'ArrowUp'
|
|
105
|
+
? Math.min(active + 1, last)
|
|
106
|
+
: event.key === 'ArrowLeft' || event.key === 'ArrowDown'
|
|
107
|
+
? Math.max(active - 1, 0)
|
|
108
|
+
: event.key === 'Home'
|
|
109
|
+
? 0
|
|
110
|
+
: event.key === 'End'
|
|
111
|
+
? last
|
|
112
|
+
: null;
|
|
113
|
+
if (next === null) return;
|
|
114
|
+
// The menu walks rows with the arrow keys; a slider keeps them for itself.
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
event.stopPropagation();
|
|
117
|
+
if (next !== current || defaultActive) onCommit(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const knobFraction = offsetOf(active, steps.length);
|
|
121
|
+
const offset = knobOffset(knobFraction);
|
|
122
|
+
// The provider default fills nothing: the knob shows where it lands, the
|
|
123
|
+
// track stays empty so it never reads as a chosen level.
|
|
124
|
+
const idle = defaultActive && drag === null;
|
|
125
|
+
const filled = idle ? 0 : knobFraction;
|
|
126
|
+
const fill = isTopLevel(active, steps.length) ? LEVEL_GRADIENT : levelColor(active, steps.length);
|
|
127
|
+
return <div
|
|
128
|
+
ref={trackRef}
|
|
129
|
+
className={css.slider}
|
|
130
|
+
role="slider"
|
|
131
|
+
tabIndex={0}
|
|
132
|
+
aria-label={label}
|
|
133
|
+
aria-valuemin={0}
|
|
134
|
+
aria-valuemax={Math.max(0, steps.length - 1)}
|
|
135
|
+
aria-valuenow={active}
|
|
136
|
+
aria-valuetext={defaultActive && drag === null ? defaultLabel : steps[active]?.label}
|
|
137
|
+
aria-disabled={busy || undefined}
|
|
138
|
+
data-busy={busy ? '' : undefined}
|
|
139
|
+
data-dragging={drag === null ? undefined : ''}
|
|
140
|
+
data-default={defaultActive && drag === null ? '' : undefined}
|
|
141
|
+
onPointerDown={onPointerDown}
|
|
142
|
+
onPointerMove={onPointerMove}
|
|
143
|
+
onPointerUp={(event) => { settle(event, true); }}
|
|
144
|
+
onPointerCancel={(event) => { settle(event, false); }}
|
|
145
|
+
onKeyDown={onKeyDown}
|
|
146
|
+
onClick={(event) => { event.stopPropagation(); }}
|
|
147
|
+
>
|
|
148
|
+
<div className={css.sliderTrack} aria-hidden="true">
|
|
149
|
+
<div className={css.sliderFill} style={{ width: knobOffset(filled), background: fill }} />
|
|
150
|
+
{steps.map((step, index) => <span
|
|
151
|
+
key={step.key}
|
|
152
|
+
className={css.sliderTick}
|
|
153
|
+
data-on={index <= active && !idle ? '' : undefined}
|
|
154
|
+
style={{ left: knobOffset(offsetOf(index, steps.length)) }}
|
|
155
|
+
/>)}
|
|
156
|
+
<div className={css.sliderKnob} style={{ left: offset }} />
|
|
157
|
+
</div>
|
|
158
|
+
</div>;
|
|
159
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tint the composer's effort caption with the level's color.
|
|
3
|
+
*
|
|
4
|
+
* The slider's fill and the menu's value already carry the level's color; the
|
|
5
|
+
* caption on the collapsed trigger is the third place the level shows, and it
|
|
6
|
+
* lives outside the menu. This entry stays mounted for the whole session and
|
|
7
|
+
* writes that one color, so all three read as one signal.
|
|
8
|
+
*
|
|
9
|
+
* The caption is recognized as the trigger's `span[class*='triggerEffort']`
|
|
10
|
+
* child — the same class-substring contract the menu row uses. When the model
|
|
11
|
+
* declares no levels the span does not exist and nothing is written.
|
|
12
|
+
*/
|
|
13
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
14
|
+
import type { ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client';
|
|
15
|
+
import { levelColor } from './level-color.ts';
|
|
16
|
+
import { effortChoices, liveReasoning } from './selection.ts';
|
|
17
|
+
|
|
18
|
+
/** Props of the tint: the shared catalog face. */
|
|
19
|
+
export type EffortTintProps = Pick<ModelSelectInjected, 'available' | 'directory'>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render nothing and keep the trigger caption colored.
|
|
23
|
+
* @param props - the session's catalog face.
|
|
24
|
+
* @returns an inert marker.
|
|
25
|
+
*/
|
|
26
|
+
export function EffortTint({ available, directory }: EffortTintProps) {
|
|
27
|
+
const markerRef = useRef<HTMLSpanElement | null>(null);
|
|
28
|
+
const [caption, setCaption] = useState<HTMLElement | null>(null);
|
|
29
|
+
const state = useSyncExternalStore(
|
|
30
|
+
(listener) => directory.subscribe(listener),
|
|
31
|
+
() => directory.getSnapshot(),
|
|
32
|
+
);
|
|
33
|
+
const levels = effortChoices(liveReasoning(state));
|
|
34
|
+
const effort = state.current?.reasoningEffort;
|
|
35
|
+
const chosen = effort === undefined ? -1 : levels.findIndex((level) => level.effort === effort);
|
|
36
|
+
// The provider default has no color of its own: the caption keeps the Host's.
|
|
37
|
+
const color = available && chosen >= 0 && levels.length >= 2
|
|
38
|
+
? levelColor(chosen, levels.length)
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
// The Host rebuilds the trigger on every model change, so the caption is
|
|
42
|
+
// re-read after each render; the identity guard keeps that from looping.
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
const card = markerRef.current?.closest('[data-composer-card]');
|
|
45
|
+
const next = card?.querySelector<HTMLElement>(
|
|
46
|
+
'[data-slot="conversation.input.model"] button[aria-haspopup="menu"] span[class*="triggerEffort"]',
|
|
47
|
+
) ?? null;
|
|
48
|
+
if (next !== caption) setCaption(next);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
if (caption === null || color === null) return;
|
|
53
|
+
const saved = caption.style.color;
|
|
54
|
+
caption.style.color = color;
|
|
55
|
+
return () => {
|
|
56
|
+
caption.style.color = saved;
|
|
57
|
+
};
|
|
58
|
+
}, [caption, color]);
|
|
59
|
+
|
|
60
|
+
return <span ref={markerRef} hidden />;
|
|
61
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge the Host's own model menu: the model cell opens the all-provider
|
|
3
|
+
* picker, and the effort cell carries the reasoning-level slider.
|
|
4
|
+
*
|
|
5
|
+
* The Host draws the root menu's first two cells itself: the model cell drills
|
|
6
|
+
* into the current provider's list, the effort cell into the levels. This plugin
|
|
7
|
+
* wants the first to open its dialog and the second to stop drilling, and the
|
|
8
|
+
* Host exposes no hook for either. This entry is mounted exactly while the root
|
|
9
|
+
* pane is open, so it can take the click on the menu surface before the cells'
|
|
10
|
+
* own handlers run and mount the slider into the effort cell.
|
|
11
|
+
*
|
|
12
|
+
* The cells are recognized as the menu surface's direct `role="menuitem"`
|
|
13
|
+
* children, which is the Host's root-menu shape; the options slot output sits in
|
|
14
|
+
* its own wrapper, so other plugins' rows never join that list. When the second
|
|
15
|
+
* cell is missing the model declares no reasoning levels and the row is left
|
|
16
|
+
* alone; when the first is missing the click falls through to the Host.
|
|
17
|
+
*
|
|
18
|
+
* Clicking the cell's label switches to the provider default, which is a state
|
|
19
|
+
* rather than a detent: the slider shows it as an empty track under a hollow
|
|
20
|
+
* knob, so it never reads as the weakest level.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
23
|
+
import { createPortal } from 'react-dom';
|
|
24
|
+
import type { ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client';
|
|
25
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
26
|
+
import { EffortSlider } from './EffortSlider.tsx';
|
|
27
|
+
import type { PickerStore } from './picker-state.ts';
|
|
28
|
+
import { effortChoices, liveReasoning, planEffortCommit, selectionOf, type Selection } from './selection.ts';
|
|
29
|
+
import css from './ModelOptions.module.css';
|
|
30
|
+
|
|
31
|
+
/** Props of the bridge: menu state, the owning session, and the seat's injected face. */
|
|
32
|
+
export type ModelMenuBridgeProps = PropsRuntime<'conversation.input.model.options'>
|
|
33
|
+
& PropsLocale<'modelSubmenu'>
|
|
34
|
+
& Pick<ModelSelectInjected, 'available' | 'directory' | 'select'>
|
|
35
|
+
& {
|
|
36
|
+
/** Session whose catalog and picker this menu drives. */
|
|
37
|
+
readonly sessionId: string;
|
|
38
|
+
/** Carries the open request to the overlay view. */
|
|
39
|
+
readonly picker: PickerStore;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render the marker, redirect the model cell, and mount the effort slider.
|
|
44
|
+
* @param props - menu state, session, catalog face, and localized copy.
|
|
45
|
+
* @returns an inert marker plus the slider while the effort cell is present.
|
|
46
|
+
*/
|
|
47
|
+
export function ModelMenuBridge({
|
|
48
|
+
sessionId, picker, dismiss, reportFailure, available, directory, select, t,
|
|
49
|
+
}: ModelMenuBridgeProps) {
|
|
50
|
+
const markerRef = useRef<HTMLSpanElement | null>(null);
|
|
51
|
+
const sliderRef = useRef<HTMLDivElement | null>(null);
|
|
52
|
+
const [surface, setSurface] = useState<HTMLElement | null>(null);
|
|
53
|
+
const [effortRow, setEffortRow] = useState<HTMLElement | null>(null);
|
|
54
|
+
const [rowLabel, setRowLabel] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const state = useSyncExternalStore(
|
|
57
|
+
(listener) => directory.subscribe(listener),
|
|
58
|
+
() => directory.getSnapshot(),
|
|
59
|
+
);
|
|
60
|
+
const current = state.current;
|
|
61
|
+
const reasoning = liveReasoning(state);
|
|
62
|
+
const levels = useMemo(() => effortChoices(reasoning), [reasoning]);
|
|
63
|
+
const declared = levels.findIndex((level) => level.effort === reasoning?.defaultEffort);
|
|
64
|
+
const chosen = current?.reasoningEffort === undefined
|
|
65
|
+
? -1
|
|
66
|
+
: levels.findIndex((level) => level.effort === current.reasoningEffort);
|
|
67
|
+
// The provider default is a state, not a detent: it is in force whenever the
|
|
68
|
+
// session carries no explicit level.
|
|
69
|
+
const defaultActive = current !== null && current.reasoningEffort === undefined;
|
|
70
|
+
const currentIndex = chosen >= 0 ? chosen : declared >= 0 ? declared : 0;
|
|
71
|
+
const defaultIndex = declared >= 0 ? declared : 0;
|
|
72
|
+
const canDefault = !defaultActive;
|
|
73
|
+
const busy = state.pending !== null;
|
|
74
|
+
// One detent is not a choice: leave the Host's row exactly as it is.
|
|
75
|
+
const showSlider = available && levels.length >= 2 && effortRow !== null;
|
|
76
|
+
|
|
77
|
+
// The Host rebuilds its cells on every render, so the surface and the effort
|
|
78
|
+
// cell are re-read after each one; the identity guards keep that from looping.
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const foundSurface = markerRef.current?.closest('[data-menu-material]');
|
|
81
|
+
const nextSurface = foundSurface instanceof HTMLElement ? foundSurface : null;
|
|
82
|
+
if (nextSurface !== surface) setSurface(nextSurface);
|
|
83
|
+
const cells = nextSurface === null
|
|
84
|
+
? []
|
|
85
|
+
: [...nextSurface.querySelectorAll<HTMLElement>(':scope > button[role="menuitem"]')];
|
|
86
|
+
const nextRow = cells.length >= 2 ? cells[1] ?? null : null;
|
|
87
|
+
if (nextRow !== effortRow) setEffortRow(nextRow);
|
|
88
|
+
const nextLabel = nextRow?.querySelector("span[class*='cellLabel']")?.textContent?.trim() ?? '';
|
|
89
|
+
if (nextLabel !== (rowLabel ?? '')) setRowLabel(nextLabel);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const submit = (selection: Selection | null): void => {
|
|
93
|
+
if (selection === null) return;
|
|
94
|
+
void select(selection).then((result) => {
|
|
95
|
+
if (result === undefined || result.ok) return;
|
|
96
|
+
reportFailure(result.error.code === 'session/writer-held'
|
|
97
|
+
? t('error.sessionInUse')
|
|
98
|
+
: t('error.action', { message: `${result.error.code}: ${result.error.message}` }));
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const commit = (index: number): void => {
|
|
102
|
+
const live = selectionOf(state);
|
|
103
|
+
const level = levels[index];
|
|
104
|
+
if (live === null || level === undefined) return;
|
|
105
|
+
submit(planEffortCommit(live, level.effort));
|
|
106
|
+
};
|
|
107
|
+
const commitDefault = (): void => {
|
|
108
|
+
const live = selectionOf(state);
|
|
109
|
+
if (live === null) return;
|
|
110
|
+
submit(planEffortCommit(live, undefined));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// The Host rebuilds `dismiss` on every render, so the listener reads the
|
|
114
|
+
// current one through a ref instead of being torn down and reinstalled.
|
|
115
|
+
const latest = useRef({ sessionId, picker, dismiss, showSlider, canDefault, commitDefault });
|
|
116
|
+
latest.current = { sessionId, picker, dismiss, showSlider, canDefault, commitDefault };
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (surface === null) return;
|
|
120
|
+
const redirect = (event: MouseEvent): void => {
|
|
121
|
+
const target = event.target;
|
|
122
|
+
if (!(target instanceof Node)) return;
|
|
123
|
+
const cells = [...surface.querySelectorAll<HTMLElement>(':scope > button[role="menuitem"]')];
|
|
124
|
+
const modelCell = cells[0];
|
|
125
|
+
if (modelCell !== undefined && modelCell.contains(target)) {
|
|
126
|
+
// Capture on the surface runs before the React root's bubble listener, so
|
|
127
|
+
// stopping here keeps the cell's own drill handler from ever running.
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
const request = latest.current;
|
|
131
|
+
request.dismiss();
|
|
132
|
+
request.picker.open(request.sessionId);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const row = cells[1];
|
|
136
|
+
if (row === undefined || !row.contains(target) || !latest.current.showSlider) return;
|
|
137
|
+
// The slider owns its own presses; only the label and the value are inert.
|
|
138
|
+
if (sliderRef.current?.contains(target) === true) return;
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
const label = row.querySelector("span[class*='cellLabel']");
|
|
142
|
+
if (latest.current.canDefault && label !== null && label.contains(target)) {
|
|
143
|
+
latest.current.commitDefault();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
sliderRef.current?.querySelector<HTMLElement>('[role="slider"]')?.focus();
|
|
147
|
+
};
|
|
148
|
+
surface.addEventListener('click', redirect, true);
|
|
149
|
+
return () => {
|
|
150
|
+
surface.removeEventListener('click', redirect, true);
|
|
151
|
+
};
|
|
152
|
+
}, [surface]);
|
|
153
|
+
|
|
154
|
+
// The slider takes the whole slack beside the label: the value and the
|
|
155
|
+
// chevron go, because the level is already written under the composer and the
|
|
156
|
+
// row no longer drills anywhere. Every write is recorded and put back.
|
|
157
|
+
useLayoutEffect(() => {
|
|
158
|
+
if (effortRow === null || !showSlider) return;
|
|
159
|
+
const label = effortRow.querySelector<HTMLElement>("span[class*='cellLabel']");
|
|
160
|
+
const value = effortRow.querySelector<HTMLElement>("span[class*='cellValue']");
|
|
161
|
+
const chevron = effortRow.querySelector<SVGElement>("svg[class*='cellChevron']");
|
|
162
|
+
const saved = {
|
|
163
|
+
value: value?.style.display ?? '',
|
|
164
|
+
chevron: chevron?.style.display ?? '',
|
|
165
|
+
cursor: label?.style.cursor ?? '',
|
|
166
|
+
title: label?.getAttribute('title') ?? null,
|
|
167
|
+
};
|
|
168
|
+
if (value !== null) value.style.display = 'none';
|
|
169
|
+
if (chevron !== null) chevron.style.display = 'none';
|
|
170
|
+
if (label !== null && canDefault) {
|
|
171
|
+
label.style.cursor = 'pointer';
|
|
172
|
+
label.title = t('effort.useDefault');
|
|
173
|
+
}
|
|
174
|
+
return () => {
|
|
175
|
+
if (value !== null) value.style.display = saved.value;
|
|
176
|
+
if (chevron !== null) chevron.style.display = saved.chevron;
|
|
177
|
+
if (label !== null) {
|
|
178
|
+
label.style.cursor = saved.cursor;
|
|
179
|
+
if (saved.title === null) label.removeAttribute('title');
|
|
180
|
+
else label.title = saved.title;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}, [effortRow, showSlider, canDefault, t]);
|
|
184
|
+
|
|
185
|
+
return <>
|
|
186
|
+
<span ref={markerRef} hidden />
|
|
187
|
+
{showSlider && effortRow !== null && createPortal(
|
|
188
|
+
<div ref={sliderRef} className={css.sliderCell}>
|
|
189
|
+
<EffortSlider
|
|
190
|
+
steps={levels}
|
|
191
|
+
current={currentIndex}
|
|
192
|
+
defaultActive={defaultActive}
|
|
193
|
+
defaultIndex={defaultIndex}
|
|
194
|
+
defaultLabel={t('effort.providerDefault')}
|
|
195
|
+
busy={busy}
|
|
196
|
+
label={rowLabel !== null && rowLabel !== '' ? rowLabel : t('effort.label')}
|
|
197
|
+
onCommit={commit}
|
|
198
|
+
/>
|
|
199
|
+
</div>,
|
|
200
|
+
effortRow,
|
|
201
|
+
)}
|
|
202
|
+
</>;
|
|
203
|
+
}
|