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: () => {