修拖动不跟手:拖动中不再 disable 滑块,松手才落盘
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m50s
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m42s
Project CI / Repository checks (pull_request) Successful in 6m29s
Project CI / Backend tests (pull_request) Successful in 8m20s
Project CI / Native shell tests (pull_request) Successful in 9m5s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m50s
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m42s
Project CI / Repository checks (pull_request) Successful in 6m29s
Project CI / Backend tests (pull_request) Successful in 8m20s
Project CI / Native shell tests (pull_request) Successful in 9m5s
- 根因(实测定位):拖动中每来一个 input 就 `selectEffort`,写盘期间 `saving` 让 range 变成 `disabled`;浏览器会当场中断正在进行的原生拖拽 —— 无头 Chromium 用真实指针序列(按下 + 8 步横移)复现:`disabled` 在首个 input 后 11ms 置真、14ms 复位,整段拖动只收到 **1 个 input 事件**,`--strength-ratio` 8 步全程冻结在 0.25 - 改法:拖动中的连续事件(`onChange`/`onInput`)只更新**预览档位**(视觉与 `--strength-ratio` 立刻跟手),落盘推迟到松手时刻:原生 `change`(range 的提交事件)+ `onKeyUp` / `onBlur` / `onPointerUp` 汇合到一个 `commitPreview()`,一个手势只写一次;range 的 `disabled` 回到只看宿主 `disabled`,不再受写盘影响 - 预览与落盘共用同一套档位枚举、`select_game_creator_reasoning_effort` 通道与「以回读值为准」逻辑,语义未变;键盘 ←/→、点轨道、拖动后离开再回来都走同一条路 - 回归测试:`chat-composer.suite.ts` 新增「拖动中保持可用 / 节点不被重挂载 / 拖动中不落盘 / 松手提交一次且值一致」用例(改前红:`拖动中不应禁用滑块:禁用会中断正在进行的原生拖拽: expected true to be false`),并把既有滑块用例的提交步骤补上"松手" - 修后同一条指针序列实测:ratio 轨迹 `[0.25,0.25,0.5,0.5,0.75,0.75,1,1]`(连续跟手)、`disabled` 全程 false、拖动中 changes=0 且松手后 changes=1、标签「当前:最高」 - 校验:typecheck、`workbenchThemeContrast` + `chatDialogFrameLayout` + `appSurface` 共 216 passed / 9 skipped、eslint、prettier、encoding、spacetime-schema、diff-check 全通过
This commit is contained in:
+63
-9
@@ -328,14 +328,24 @@ export function ComposerReasoningEffortSelect({
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
/*
|
||||
拖动中的预览档位:`input` 只更新它(视觉立刻跟手),松手 / 抬键才真正落盘。
|
||||
这样写盘不会发生在拖动过程中——写盘会置 `saving`,一旦用它 disable 掉原生 range,
|
||||
浏览器会当场中断正在进行的拖拽(实测:拖动 6 步只收到 1 个 input 事件,
|
||||
圆钮停在第一格不动)。
|
||||
*/
|
||||
const [previewEffort, setPreviewEffort] =
|
||||
useState<GameCreatorLlmReasoningEffort | null>(null);
|
||||
const previewEffortRef = useRef<GameCreatorLlmReasoningEffort | null>(null);
|
||||
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const mountedRef = useRef(true);
|
||||
const options = composerReasoningEffortOptions();
|
||||
const shownEffort = previewEffort ?? effort;
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
options.findIndex((option) => option.value === effort),
|
||||
options.findIndex((option) => option.value === shownEffort),
|
||||
);
|
||||
const activeLabel = options[activeIndex]?.label ?? effort;
|
||||
const activeLabel = options[activeIndex]?.label ?? shownEffort;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -344,6 +354,25 @@ export function ComposerReasoningEffortSelect({
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
落盘时机交给原生 `change`:range 的 `change` 就是"值被提交"那一刻(鼠标松手 /
|
||||
键盘一次调整),`input` 才是拖动过程中的连续事件。React 的 `onChange` 对 range
|
||||
映射的是 `input`(连续),所以这里直接挂原生监听,避免把连续事件当成提交。
|
||||
*/
|
||||
const rangeRef = useRef<HTMLInputElement | null>(null);
|
||||
const commitPreviewRef = useRef<() => void>(() => undefined);
|
||||
useEffect(() => {
|
||||
const node = rangeRef.current;
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
const handleChange = () => commitPreviewRef.current();
|
||||
node.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
node.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -405,6 +434,27 @@ export function ComposerReasoningEffortSelect({
|
||||
});
|
||||
}
|
||||
|
||||
/** 拖动中的连续事件只改预览:视觉立刻跟手,落盘留到松手那一刻。 */
|
||||
function previewFromEvent(event: { currentTarget: HTMLInputElement }) {
|
||||
const next = options[Number(event.currentTarget.value)];
|
||||
if (!next || next.value === shownEffort) {
|
||||
return;
|
||||
}
|
||||
previewEffortRef.current = next.value;
|
||||
setPreviewEffort(next.value);
|
||||
}
|
||||
|
||||
/** 松手 / 抬键 / 失焦时把预览落盘(一个手势只写一次)。 */
|
||||
function commitPreview() {
|
||||
const next = previewEffortRef.current;
|
||||
previewEffortRef.current = null;
|
||||
setPreviewEffort(null);
|
||||
if (next && next !== effort) {
|
||||
selectEffort(next);
|
||||
}
|
||||
}
|
||||
commitPreviewRef.current = commitPreview;
|
||||
|
||||
/*
|
||||
下拉形态(策划等宿主原样沿用):与模型选择器同一套观感,复用 `conversation-model-*`
|
||||
的触发钮与浮层样式。
|
||||
@@ -480,6 +530,7 @@ export function ComposerReasoningEffortSelect({
|
||||
{/* 原生 range 的 role/值域本来就成立,这里再显式写一遍:`aria-valuetext` 是
|
||||
中文档位名,读屏念的是「最高」而不是「4」。 */}
|
||||
<input
|
||||
ref={rangeRef}
|
||||
type="range"
|
||||
className="project-chat-composer-strength-range"
|
||||
min={0}
|
||||
@@ -497,13 +548,16 @@ export function ComposerReasoningEffortSelect({
|
||||
aria-valuemax={options.length - 1}
|
||||
aria-valuenow={activeIndex}
|
||||
aria-valuetext={activeLabel}
|
||||
disabled={disabled || saving}
|
||||
onChange={(event) => {
|
||||
const next = options[Number(event.currentTarget.value)];
|
||||
if (next && next.value !== effort) {
|
||||
selectEffort(next.value);
|
||||
}
|
||||
}}
|
||||
/* 拖动中保持可用(写盘只在松手后发生),视觉靠预览值跟手。
|
||||
`onInput`(浏览器里连续触发)与 `onChange`(React 对 range 的常规映射)都接,
|
||||
两者更新同一个预览,重复调用没有副作用。 */
|
||||
disabled={disabled}
|
||||
onChange={previewFromEvent}
|
||||
onInput={previewFromEvent}
|
||||
/* 松手 / 抬键 / 失焦才落盘:拖动中写盘会 disable 控件、原生拖拽当场中断。 */
|
||||
onPointerUp={commitPreview}
|
||||
onKeyUp={commitPreview}
|
||||
onBlur={commitPreview}
|
||||
/>
|
||||
{/* 档位点叠在轨道上(纯装饰,命中与吸附由原生 range 负责)。 */}
|
||||
<span
|
||||
|
||||
@@ -1085,8 +1085,9 @@ export function registerChatComposerControlTests() {
|
||||
// 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。
|
||||
expect(listbox.contains(slider)).toBe(false);
|
||||
|
||||
// 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。
|
||||
// 拖动 / 点档位:连续事件只改预览,松手(这里用抬键路径)才落到既有持久化通道与取值。
|
||||
fireEvent.change(slider, { target: { value: '1' } });
|
||||
fireEvent.keyUp(slider);
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'select_game_creator_reasoning_effort',
|
||||
@@ -1129,6 +1130,58 @@ export function registerChatComposerControlTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('strength slider: drag follows continuously and only commits once on release', async () => {
|
||||
let stored = 'default';
|
||||
const { invoke, surface } = await openDirectCodexSurface({
|
||||
select_game_creator_reasoning_effort: (args) => {
|
||||
stored = String(args?.effort ?? '');
|
||||
return gameCreatorConfigView(stored);
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(within(surface).getByRole('button', { name: '对话模型' }));
|
||||
const slider = within(surface).getByRole('slider', {
|
||||
name: '思维强度',
|
||||
}) as HTMLInputElement;
|
||||
const writes = () =>
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'select_game_creator_reasoning_effort',
|
||||
).length;
|
||||
|
||||
// 拖动中:连续 input 必须逐步跟手;既不能把控件 disable(原生拖拽会被中断),
|
||||
// 也不能每格都落盘(写盘回读会把拖动中的值顶回去)。
|
||||
// jsdom 对受控 range 的值回写与指针事件不全(不实现 PointerEvent、原生 change 不触发),
|
||||
// 所以这里断言环境能看到的契约:控件保持可用、节点不被重挂载、拖动中不落盘;
|
||||
// 真实的「按下 → 多步移动 → 抬起」指针序列与松手落盘在无头 Chromium 夹具里验证(见 PR 正文)。
|
||||
for (const value of ['0', '1', '2'] as const) {
|
||||
fireEvent.input(slider, { target: { value } });
|
||||
expect(
|
||||
within(surface).getAllByRole('slider'),
|
||||
'拖动期间只应有一颗滑块',
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
within(surface).getByRole('slider', { name: '思维强度' }),
|
||||
'滑块节点不能因 input 更新被重挂载',
|
||||
).toBe(slider);
|
||||
expect(
|
||||
slider.disabled,
|
||||
'拖动中不应禁用滑块:禁用会中断正在进行的原生拖拽',
|
||||
).toBe(false);
|
||||
expect(writes(), '拖动过程中不落盘').toBe(0);
|
||||
}
|
||||
|
||||
// 松手才提交一次,取值走既有通道、与旧下拉一致(jsdom 里用抬键路径代替松手,见上)。
|
||||
fireEvent.keyUp(slider);
|
||||
await waitFor(() => {
|
||||
expect(writes()).toBe(1);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'select_game_creator_reasoning_effort',
|
||||
{ effort: 'medium' },
|
||||
);
|
||||
expect(stored).toBe('medium');
|
||||
});
|
||||
|
||||
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
|
||||
const { surface } = await openDirectCodexSurface({
|
||||
load_game_creator_llm_models: () => {
|
||||
|
||||
Reference in New Issue
Block a user