From ff287c19fc36253b2b3306634b2ab0099c285756 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 14:53:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8B=E6=8B=89=E5=BC=B9?= =?UTF-8?q?=E5=B1=82=E6=94=B9=E5=9B=9E=E4=BB=A5=E8=A7=A6=E5=8F=91=E9=92=AE?= =?UTF-8?q?=E4=B8=BA=E9=94=9A=E7=82=B9=EF=BC=8C=E8=BE=93=E5=85=A5=E5=8C=BA?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E5=8F=98=E5=8C=96=E4=B8=8D=E5=86=8D=E7=89=B5?= =?UTF-8?q?=E5=8A=A8=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles.css:direct-codex 控制排里的 .conversation-model-select 由 position: static !important 改为 position: relative !important(只改这一个词,另补一段注释说明它同时是弹层锚点)。这颗容器是 .conversation-model-menu(position: absolute; right: 0; bottom: calc(100% + 8px))与模型提示([role='alert'] / [role='status'],position: absolute; bottom: 36px)的包含块;写成 static 时包含块回落到最近的有定位祖先——也就是 position: absolute 的 composer 盒子,菜单于是出现在整个 composer 上方、与触发钮之间隔着整个输入区,输入区一变高(多行、引用 chip、AI 润色)菜单与提示跟着往上飘:这就是"编辑框把元素挤开"的形态。 - styles.css:right/bottom: auto、flex: 0 1 auto、min-width: 0、max-width: 180px 全部保持原样——relative 不会把模型选择钮移出控制排,也不改它被压缩换行的行为;交互语义零改动(打开/选择/刷新模型、发送、@ 引用、快速模式全在原位)。 - project-development.suite.ts:新增一条声明级守卫。① 弹层不参与父容器流式布局(菜单与模型提示都是 absolute,菜单 bottom: calc(100% + …)、right: 0);② 锚点必须是有定位的容器(relative 且不是 static),同时仍在原位(right/bottom auto、flex 0 1 auto、min-width 0);③ 输入区高度变化不得推动同排操作元素(composer 是底边锚定的两行网格:position: absolute + bottom: 12px + grid-template-rows: auto auto;控制排是单行 flex 且不换行,@ 与发送钮 flex: 0 0 30px 不可压缩,窄屏下被压缩的是可收缩的模型选择钮)。 - conversationModelSelect.test.tsx:新增一条 DOM 守卫——菜单必须是 .conversation-model-select 的直接子节点,且不得成为控制排容器 .project-supervisor-composer-controls-right 的流式子节点。 - 变异验证(均已实测红灯):① 锚点改回 static → "expected … to match /position:\s*relative\s*!important/";② 菜单改成 position: static(弹层回流式)→ "expected … to match /position:\s*absolute/";③ 把菜单渲染挪出触发钮容器(变成同排容器的流式子节点)→ DOM 守卫 "expected null not to be null"。 - 门禁:typecheck exit 0;AGC 子集 1205 passed / 4 skipped / 0 failed(基线 1203 + 本次新增 2 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;本次三个文件单独 `git diff --check` exit 0(全量 `git diff --check` 目前报的是另一位 Agent 在途修改的 packages/shared/src/components/styles.css:1281 EOF 空行,与本次无关)。 --- apps/ai-game-creator-shell/src/styles.css | 10 +++- .../appSurface/project-development.suite.ts | 60 +++++++++++++++++++ .../tests/conversationModelSelect.test.tsx | 26 ++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 10bd15886..96fca92e0 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -9897,6 +9897,14 @@ iframe.preview-frame { margin-left: auto; } +/* 模型选择钮在控制排里按流式排布,但它同时是**弹层锚点**:`.conversation-model-menu` + (`bottom: calc(100% + 8px)`)与模型提示(`[role='alert'] / [role='status']`, + `bottom: 36px`)都是 `position: absolute`,包含块必须是这颗容器。这里原来写的是 + `position: static !important`(当初为抵消上面那条遗留的「模型选择钮绝对定位」规则), + 锚点于是回落到最近的有定位祖先——也就是 `position: absolute` 的 composer 盒子: + 菜单跑到**整个 composer 上方**、和触发钮之间隔着整个输入区,输入区一变高(多行、 + 引用 chip、AI 润色)菜单与提示就跟着往上飘,看起来就是"编辑框把弹层挤开了"。 + `relative` 不会把它移出控制排(`right/bottom: auto` 仍在原位),只补回锚点。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex @@ -9904,7 +9912,7 @@ iframe.preview-frame { .conversation-model-select { flex: 0 1 auto; min-width: 0; - position: static !important; + position: relative !important; right: auto !important; bottom: auto !important; max-width: 180px; 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 3ab9fa620..5eac03e00 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 @@ -5763,6 +5763,66 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => expect(trigger.textContent).toContain('快速')); }); + it('anchors the model dropdown to its own trigger instead of the composer box', () => { + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + + // 弹层自己不参与父容器流式布局:菜单与模型提示都是 absolute,打开它不会给控制排 + // 添一个流式子节点("弹层撑高容器 / 把同排元素挤开"这一类问题的前提)。 + const menu = styleRuleBody(styles, '\\.conversation-model-menu'); + expect(menu).toMatch(/position:\s*absolute/u); + expect(menu).toMatch(/bottom:\s*calc\(100%\s*\+\s*\d+px\)/u); + expect(menu).toMatch(/right:\s*0/u); + const actionNotice = styleRuleBody( + styles, + "\\.conversation-model-select \\[role='status'\\]", + ); + expect(actionNotice).toMatch(/position:\s*absolute/u); + + // 锚点:direct-codex 控制排里的模型选择钮容器必须是**有定位**的,否则上面两个 + // absolute 弹层的包含块会回落到 `position: absolute` 的 composer 盒子——菜单就会 + // 跑到整个 composer 上方,和触发钮之间隔着输入区,输入区一变高菜单跟着往上飘。 + const anchor = styleRuleBody( + styles, + '\\.project-supervisor-composer-controls\\s+\\n?\\s*\\.conversation-model-select', + ); + expect(anchor).toMatch(/position:\s*relative\s*!important/u); + expect(anchor).not.toMatch(/position:\s*static/u); + // 补锚点不能把它移出控制排:仍在原位(right/bottom auto)、仍可被压缩换行。 + expect(anchor).toMatch(/right:\s*auto/u); + expect(anchor).toMatch(/bottom:\s*auto/u); + expect(anchor).toMatch(/flex:\s*0 1 auto/u); + expect(anchor).toMatch(/min-width:\s*0/u); + + // 输入区高度变化不得推动同排操作元素:composer 是底边锚定的两行网格,输入区占第一 + // 行、控制排占第二行;输入区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍钉在 + // composer 底边(`bottom: 12px`),发送钮与模型选择钮不动。 + const composer = styleRuleBody( + styles, + '\\.game-workbench-chat\\s+\\.project-supervisor-surface\\.is-direct-codex\\s+\\.project-supervisor-composer\\.is-direct-codex', + ); + expect(composer).toMatch(/position:\s*absolute/u); + expect(styleNumber(composer, 'bottom')).toBeGreaterThan(0); + expect(composer).toMatch(/grid-template-rows:\s*auto auto/u); + // 控制排是单行 flex、不换行:窄屏下靠可压缩的模型选择钮(flex 0 1 auto + min-width 0) + // 收窄,而不是把模型选择钮/发送钮挤到第二行。 + const controls = styleRuleBody( + styles, + '\\.project-supervisor-composer-controls', + ); + expect(controls).toMatch(/display:\s*flex/u); + expect(controls).not.toMatch(/flex-wrap/u); + // `@` 引用钮与发送钮共用一条尺寸规则(同一规则体里两个选择器),都必须不可压缩, + // 这样窄屏下被压的是可收缩的模型选择钮,而不是把这两个方钮挤出这一行。 + const controlsButtons = styleRuleBody( + styles, + '\\.project-supervisor-composer-controls\\s+\\n?\\s*\\.project-supervisor-submit-button', + ); + expect(controlsButtons).toMatch(/flex:\s*0 0 30px/u); + }); + it('runs a top workbench play request without a second confirmation', async () => { const projectPath = '/tmp/top-play-request'; const supervisorHarness = createProjectSupervisorRuntimeHarness({ diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 25b1372f8..4a9c7731f 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -127,6 +127,32 @@ test('a failed save keeps submission unavailable', async () => { expect(onReady).toHaveBeenLastCalledWith(false); }); +test('keeps the open menu inside its own trigger container, not the control row', async () => { + const onReady = vi.fn(); + const { container } = render( + <> +
+ +
+ , + ); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + // 弹层必须留在触发钮自己那颗容器里:它是 absolute + bottom: 100% 的浮层,锚点由这颗 + // 容器提供;一旦被挪到控制排(或其它同排容器)当流式子节点,就会参与那一排的布局。 + const modelSelect = container.querySelector('.conversation-model-select'); + expect(modelSelect).not.toBeNull(); + expect( + modelSelect?.querySelector(':scope > .conversation-model-menu'), + ).not.toBeNull(); + expect( + container.querySelector( + '.project-supervisor-composer-controls-right > .conversation-model-menu', + ), + ).toBeNull(); +}); + test('closes the menu when clicking outside', async () => { const onReady = vi.fn(); render();