diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 3d69a9dc9..266eef271 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -1,5 +1,5 @@ import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; -import type { Ref } from 'react'; +import type { ReactNode, Ref } from 'react'; import { useCallback, useEffect, @@ -67,7 +67,7 @@ export function ConversationModelSelect({ ref, errorPresentation = 'inline', onUnavailable, - onModelLabelChange, + strengthSection, }: { className?: string; disabled: boolean; @@ -85,8 +85,15 @@ export function ConversationModelSelect({ errorPresentation?: 'inline' | 'toast'; /** `errorPresentation: 'toast'` 时,对**新出现**的失败消息回调一次(同一条不重复)。 */ onUnavailable?: (message: string) => void; - /** 当前选中模型的显示名(目录没读完时是空串);供宿主在别处只读展示。 */ - onModelLabelChange?: (label: string) => void; + /** + * 菜单顶部的附加区(Direct 输入盒用来放「思维强度」滑块)。 + * + * 传了它才会把弹层包一层 `.conversation-model-menu-shell`:外壳负责定位 / 描边 / 底色, + * 强度区在上、模型列表(`role="listbox"`)在下,滑块**不进** listbox 子树 + * (参数控件放进菜单角色里会让读屏进菜单模式后读不到滑块)。 + * 不传时渲染与改动前完全一致,策划输入盒、首页、画布都不受影响。 + */ + strengthSection?: ReactNode; }) { const initialCatalog = cachedLlmModelCatalog(); const [models, setModels] = useState( @@ -118,11 +125,9 @@ export function ConversationModelSelect({ }, [onReady]); const onUnavailableRef = useRef(onUnavailable); - const onModelLabelChangeRef = useRef(onModelLabelChange); useEffect(() => { onUnavailableRef.current = onUnavailable; - onModelLabelChangeRef.current = onModelLabelChange; - }, [onModelLabelChange, onUnavailable]); + }, [onUnavailable]); /** 已经报出去的失败消息:同一条只在「新出现」时报一次,失败清空后可以再报一次。 */ const reportedErrorRef = useRef(''); @@ -141,12 +146,6 @@ export function ConversationModelSelect({ onUnavailableRef.current?.(error); }, [error, errorPresentation]); - const selectedModelLabel = - models.find((model) => model.id === selected)?.displayName ?? ''; - useEffect(() => { - onModelLabelChangeRef.current?.(selectedModelLabel); - }, [selectedModelLabel]); - useEffect(() => { mountedRef.current = true; return () => { @@ -433,6 +432,53 @@ export function ConversationModelSelect({ } } + const modelMenu = ( +
+ {busy && models.length === 0 ? ( + 正在读取模型 + ) : null} + {models.map((model) => ( + + ))} +
+ +
+ ); + return (
- + } /> { + it('puts the reasoning strength inside the model menu and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ select_game_creator_reasoning_effort: (args) => { @@ -1047,32 +1047,43 @@ export function registerChatComposerControlTests() { }, }); - // 触发钮固定文案「选择强度」:当前档位只在 aria-label / title 与浮层里出现。 - const trigger = within(surface).getByRole('button', { name: /选择强度/ }); - expect(trigger.textContent).toContain('选择强度'); - await waitFor(() => { - expect(trigger.getAttribute('aria-label')).toContain('当前 高'); - }); - expect(trigger.getAttribute('title')).toContain('当前:高'); - // 触发钮就在模型选择器这一排(同一控制排容器里),关闭态没有滑块。 + // 底行只留一颗控件:模型触发钮;独立的「选择强度 / 推理档」触发钮不再存在。 + const row = surface.querySelector( + '.project-chat-composer-controls-right', + ) as HTMLElement | null; + expect(row).not.toBeNull(); expect( - within(surface).getByRole('button', { name: '对话模型' }), - ).not.toBeNull(); + within(row as HTMLElement).queryAllByRole('button', { + name: /选择强度|推理档/, + }), + ).toHaveLength(0); + const trigger = within(row as HTMLElement).getByRole('button', { + name: '对话模型', + }); expect(trigger.closest('.project-chat-composer-controls')).not.toBeNull(); + // 关闭态既没有滑块,也没有菜单。 expect( within(surface).queryByRole('slider', { name: '思维强度' }), ).toBeNull(); - // 点开 popover:滑块报出 role/值域与中文档位名。 + // 打开模型菜单:强度区在顶部,同一个菜单里还有模型列表。 fireEvent.click(trigger); const slider = within(surface).getByRole('slider', { name: '思维强度', }) as HTMLInputElement; + // 强度区随菜单挂载后才去读一次配置(`read_game_creator_app_config`),等回读到位。 + await waitFor(() => { + expect(slider.getAttribute('aria-valuetext')).toBe('高'); + }); expect(slider.getAttribute('aria-valuemin')).toBe('0'); expect(slider.getAttribute('aria-valuemax')).toBe('4'); expect(slider.getAttribute('aria-valuenow')).toBe('3'); - expect(slider.getAttribute('aria-valuetext')).toBe('高'); expect(within(surface).getByText('当前:高')).not.toBeNull(); + const listbox = within(surface).getByRole('listbox', { + name: '对话模型', + }); + // 滑块不能落进 listbox 子树:参数控件混进菜单角色会让读屏进菜单模式后读不到它。 + expect(listbox.contains(slider)).toBe(false); // 拖动 / 点档位走原生 range 的 change,落到与旧下拉同一条持久化通道与取值。 fireEvent.change(slider, { target: { value: '1' } }); @@ -1082,13 +1093,9 @@ export function registerChatComposerControlTests() { { effort: 'low' }, ); }); - // 以落盘后的回读值为准;改档后浮层保持打开(连续调节不用重开)。 + // 以落盘后的回读值为准;改档后菜单保持打开(连续调节不用重开)。 await waitFor(() => { - expect( - within(surface) - .getByRole('button', { name: /选择强度/ }) - .getAttribute('aria-label'), - ).toContain('当前 低'); + expect(within(surface).getByText('当前:低')).not.toBeNull(); }); expect(stored).toBe('low'); expect( @@ -1101,14 +1108,16 @@ export function registerChatComposerControlTests() { ), ).toBe(false); - // Esc 关闭;再点开、点外部也关闭。 + // Esc 关掉整个菜单;再打开、点外部也关。 fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => { expect( within(surface).queryByRole('slider', { name: '思维强度' }), ).toBeNull(); }); - fireEvent.click(within(surface).getByRole('button', { name: /选择强度/ })); + fireEvent.click( + within(row as HTMLElement).getByRole('button', { name: '对话模型' }), + ); expect( within(surface).getByRole('slider', { name: '思维强度' }), ).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index f19d826ae..508999d0c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -309,6 +309,9 @@ export function registerDesignAgentSurfaceTests() { ).toContain('低'); }); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + // 策划面菜单保持原样:只有模型列表,没有 Direct 侧专属的强度滑块。 + expect(screen.getByRole('listbox', { name: '对话模型' })).not.toBeNull(); + expect(screen.queryByRole('slider', { name: '思维强度' })).toBeNull(); fireEvent.click(await screen.findByRole('option', { name: '快速' })); await waitFor(() => { expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b3d761ea9..8004a2abf 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5979,6 +5979,13 @@ export function registerProjectWorkbenchFoundationTests() { 'background: var(--platform-input-fill);', ); expect(composerRule?.[1]).not.toContain('border-top:'); + // 强度并入模型菜单后,底行不再有第二颗控件:独立触发钮与它自己的浮层整段删除, + // 换成模型菜单外壳(外壳定位 / 朝向与原来的模型菜单一致:贴触发钮右缘、向上展开)。 + expect(styles).not.toContain('project-chat-composer-strength-trigger'); + expect(styles).not.toContain('project-chat-composer-strength-popover'); + expect(styles).toMatch( + /\.conversation-model-menu-shell\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*calc\(100% \+ 8px\)/s, + ); expect(styles).toMatch( /\.game-workbench-chat \.pending-command-actions button\s*\{[^}]*min-width:\s*52px[^}]*flex:\s*0 0 auto/s, ); diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index 436d1ce3d..31668b9ad 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -331,29 +331,38 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(paddingBottom).toBeLessThan(total); }); - it('思维强度:触发钮留在工具条同一排,档位收进 popover 的滑块(宽屏)', () => { + it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => { const trigger = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength-trigger`, COMPOSER_TRIGGER, COMPOSER_TRIGGER_BASE, ); - const anchor = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength`, + const select = desktopDeclarations( + `${COMPOSER_CONTROLS} .conversation-model-select`, ); - const popover = desktopDeclarations( - `${COMPOSER} .project-chat-composer-strength-popover`, + const shell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell`, + ); + const menuInShell = desktopDeclarations( + `${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`, + ); + const section = desktopDeclarations( + `${COMPOSER} .project-chat-reasoning-effort.is-section`, ); const range = desktopDeclarations( `${COMPOSER} .project-chat-composer-strength-range`, ); - // 触发钮沿用工具条那颗 30px 控件的高度和类:工具条行高仍是 30(见上面的算式)。 - expect(pixelValue(trigger, 'height')).toBe(30); - // 浮层锚在强度控件上、从工具条上方展开,不占工具条宽度;滑块占满浮层宽度。 - expect(declaration(anchor, 'position')).toBe('relative'); - expect(declaration(popover, 'position')).toBe('absolute'); - expect(declaration(popover, 'bottom')).toBe('calc(100% + 8px)'); + // 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。 + expect(declaration(select, 'position')).toContain('relative'); + expect(declaration(shell, 'position')).toBe('absolute'); + expect(declaration(shell, 'bottom')).toBe('calc(100% + 8px)'); + expect(declaration(shell, 'right')).toBe('0'); + // 里层菜单回到文档流(位置 / 描边 / 底色归外壳),强度区是菜单顶部的一块网格,滑块占满宽度。 + expect(declaration(menuInShell, 'position')).toBe('static'); + expect(declaration(section, 'display')).toBe('grid'); expect(declaration(range, 'width')).toBe('100%'); + // 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。 + expect(pixelValue(trigger, 'height')).toBe(30); }); it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6c17a0e97..1bcc9dbbb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -10,13 +10,15 @@ - **判据/取证**:`tests/appSurface/chat-composer.suite.ts`——不可用时产出 toast,且输入区子树里既没有 `[role='alert']` 也没有旧文案;模型失败只报一次、清空后再出现才再报。 - **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 -## 2026-10-04 输入盒的档位选择用 popover + 滑块,触发钮只占一颗控件宽 +## 2026-10-04 输入盒的强度选择并入模型菜单,底行只留一颗控件 -- 触发钮固定文案「选择强度」,当前档位只出现在 `aria-label` / `title` 与浮层里的「当前:最高」;滑块直接用原生 ``:`role="slider"`、`aria-valuemin/max/now` 与 ←/→/Home/End 都是原生行为,`aria-valuetext` 报中文档位名,点轨道或拖拽按 `step` 吸附到档位。 -- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**,只换表现形式;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`,策划输入盒继续走下拉(零语义变化,差分审计仍为 0 处声明变化)。 -- 点档位后浮层**保持打开**(连续调节不用重开),关闭走 Esc 与点击外部;浮层几何沿用 `.project-chat-attachment-menu` 同一套约定,颜色只取 `--platform-*`。 -- **判据/取证**:`tests/appSurface/chat-composer.suite.ts` 的滑块用例(打开 / 取值 / 持久化 / 关闭路径)+ `tests/chatDialogFrameLayout.test.ts` 的浮层几何断言。 -- **关联**:`view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx`、`src/styles.css`。 +- 目标口径(用户复核):**减少底部空间占用** —— 底行只能有模型触发钮一颗控件。思维强度不再单独占位,而是作为**强度区嵌在模型菜单顶部**(标题「思维强度」+「当前:最高」+ 原生 `` + 5 个档位点),与模型列表共用同一张卡(`.conversation-model-menu-shell`,向上展开、贴触发钮右缘,里层 `.conversation-model-menu` 回到文档流只负责滚动)。 +- **滑块不能放进 `role="listbox"` 子树**:参数控件混进菜单角色会让读屏进菜单模式后读不到它,所以外壳是普通容器(强度区 + listbox 并列),只能靠 `strengthSection` 这个显式 prop 由宿主注入。 +- 档位枚举、默认值、持久化通道(读 `read_game_creator_app_config`、写 `select_game_creator_reasoning_effort`)与旧下拉**完全一致**;`ComposerReasoningEffortSelect` 的 `presentation` 默认 `'dropdown'`(策划输入盒继续用,零语义变化),Direct 用 `'section'`;`ConversationModelSelect` 不传 `strengthSection` 时渲染与改动前逐字一致(首页 / 策划 / 画布不受影响)。 +- 关掉旧的独立触发钮(底行元素 2 → 1);浮层的开合、Esc 与点击外部关闭都由模型菜单既有行为负责,强度区不再自带浮层。 +- **取舍**:强度区随菜单挂载,打开时先显示「默认」再回读成落盘档位(一次 IPC 往返),测试里要 `waitFor`;换掉这一点需要把档位状态提到宿主,暂不做。 +- **判据/取证**:`tests/appSurface/chat-composer.suite.ts`(底行不再有第二颗控件、菜单里改档落同一条持久化通道、Esc/点外部关闭、滑块不在 listbox 内)+ `tests/chatDialogFrameLayout.test.ts`(外壳向上展开 / 强度区网格 / 底行高度不增)+ `tests/appSurface/design-agent.suite.ts`(策划菜单里没有滑块)。 +- **关联**:`features/project-workspace/ConversationModelSelect.tsx`、`view/project-development/chat/components/DirectProjectComposer/{DirectProjectComposer.tsx,ComposerControls.tsx}`、`src/styles.css`。 ## 2026-10-03 AGC 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位