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( + <> +