模型下拉弹层改回以触发钮为锚点,输入区高度变化不再牵动它

- 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 空行,与本次无关)。
This commit is contained in:
2026-09-11 14:53:11 +08:00
parent 9c4778c881
commit ff287c19fc
3 changed files with 95 additions and 1 deletions
+9 -1
View File
@@ -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;
@@ -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({
@@ -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(
<>
<div className="project-supervisor-composer-controls-right">
<ConversationModelSelect disabled={false} onReady={onReady} />
</div>
</>,
);
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(<ConversationModelSelect disabled={false} onReady={onReady} />);