From 4227724b33887cfd0e58fec8162a4021fcdb9163 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 10:46:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=8B=96=E5=8A=A8=E4=B8=8D=E8=B7=9F?= =?UTF-8?q?=E6=89=8B=EF=BC=9A=E6=8B=96=E5=8A=A8=E4=B8=AD=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=20disable=20=E6=BB=91=E5=9D=97=EF=BC=8C=E6=9D=BE=E6=89=8B?= =?UTF-8?q?=E6=89=8D=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因(实测定位):拖动中每来一个 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 全通过 --- .../ComposerControls.tsx | 72 ++++++++++++++++--- .../tests/appSurface/chat-composer.suite.ts | 55 +++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 31c7bd694..cccb0b5a3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -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(null); + const previewEffortRef = useRef(null); const writeChainRef = useRef>(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(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」。 */} { - 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 负责)。 */} { 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: () => {