修拖动不跟手:拖动中不再 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

- 根因(实测定位):拖动中每来一个 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:
2026-10-05 10:46:02 +08:00
parent 9b148144fa
commit 4227724b33
2 changed files with 117 additions and 10 deletions
@@ -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: () => {