From c197edb203136f328a844d94d8282a2b4f89f578 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:13:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=BA=A6=E6=BB=91=E5=9D=97=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E6=A8=A1=E5=9E=8B=E8=8F=9C=E5=8D=95=EF=BC=9A=E5=BA=95?= =?UTF-8?q?=E8=A1=8C=E5=8F=AA=E5=89=A9=E4=B8=80=E9=A2=97=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E6=A0=B8=E5=8F=8D=E9=A6=88=202=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ConversationModelSelect` 新增 `strengthSection`:传了它就把弹层包一层 `.conversation-model-menu-shell`(强度区在上、`role="listbox"` 模型列表在下,共用同一张卡);不传时渲染与改动前逐字一致(首页 / 策划 / 画布不变) - 强度区不再自带触发钮与浮层:`ComposerReasoningEffortSelect` 的 `presentation` 由 `'slider'` 改为 `'section'`(只渲染「思维强度」标题 + 原生 range + 5 档位点 + 「当前:最高」),开合 / Esc / 点外部交给模型菜单既有行为;Direct 输入盒把它作为 `strengthSection` 注入,底行元素 2 → 1 - 删掉上一轮的独立触发钮与其专用 CSS(`project-chat-composer-strength-trigger` / `-strength-popover` / `-strength-model` 与 `-strength` 锚点);同时删掉不再使用的 `onModelLabelChange` 回调——强度区不再复述模型名(触发钮与列表已处处显示模型,重复信息优先去掉以省空间) - 新增外壳 CSS:定位 / 朝向沿用原模型菜单(贴触发钮右缘、向上展开、不盖输入框),里层菜单回到文档流只负责滚动,超长模型名改走省略号 - 测试:`chat-composer.suite.ts` 改为「打开模型菜单 → 滑块在顶部 → 改档落同一条持久化通道 → Esc/点外部关闭」,并断言底行不再有第二颗控件、滑块不在 listbox 子树里;`chatDialogFrameLayout.test.ts` 断言外壳向上展开 / 强度区网格 / 底行高度不增;`design-agent.suite.ts` 断言策划菜单里没有滑块;`project-development.suite.ts` 断言旧触发钮/浮层 CSS 已删 - pitfalls 同步改写该条(含「滑块不能进 listbox」与「强度区随菜单挂载,打开时先显示默认再回读」的取舍) - 校验:4 个 vitest 文件 224 passed / 9 skipped、typecheck、eslint、prettier、encoding、spacetime-schema、diff-check 全通过 --- .../ConversationModelSelect.tsx | 127 +++++++------- apps/ai-game-creator-shell/src/styles.css | 115 ++++++++----- .../ComposerControls.tsx | 155 ++++++------------ .../DirectProjectComposer.tsx | 15 +- .../tests/appSurface/chat-composer.suite.ts | 51 +++--- .../tests/appSurface/design-agent.suite.ts | 3 + .../appSurface/project-development.suite.ts | 7 + .../tests/chatDialogFrameLayout.test.ts | 33 ++-- docs/project-memory/shared-memory/pitfalls.md | 14 +- 9 files changed, 272 insertions(+), 248 deletions(-) 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 输入区控件叠在一起:输入区内的操作排与状态提示不能用绝对定位