润色钮挪到发送钮左边(右组相邻成组)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- 口径:AI 润色 / 恢复原文与发送钮同为圆角矩形按钮,放一起更协调 → 从「左组 `@` `+` 之后」挪到**右组最末、发送钮左侧**,最终顺序 `@` `+` | 模型 麦克风 润色 发送(`space-between` 左右分组不变)
- 实现:portal 挂点搬进右组(`DirectProjectComposer` 里把 `.project-chat-composer-controls-actions` 移到 `-controls-right` 内、发送钮之前),`actionsPortalTarget` 的「可选 prop、不传则行为不变」契约保持;用显式槽位而不是 CSS `order`,因为 DOM 顺序即 Tab 与读屏顺序(现在就是 模型 → 麦克风 → 润色 → 发送,与视觉一致)
- 注释/文档同步:composer 与 styles.css 里「与 `@` / `+` 同组、排在左组最后一个」那类旧口径全部改成新口径(「与发送同为圆角矩形按钮、相邻成组」),pitfalls 同段一并更新;全仓 grep `输入类动作` / `生成类动作` / `排在左组` / `左组最后一个` 命中 0
- 测试:`chat-composer.suite.ts` 的 DOM 断言改成新挂点——润色钮必须在 `.project-chat-composer-controls` 内、父链是 `-controls-right`、**`slot.nextElementSibling` 就是发送钮**、且 `compareDocumentPosition` 证明润色在发送之前;`chatDialogFrameLayout.test.ts` 的源码顺序断言改成「挂点在 `-controls-right` 之后、`{canStopTurn` 之前」;空态「工具条之外无第二个按钮」计数仍为 6;七宽度单行/不相交用例不变(含新位置)
- min-width 复核(无头 Chromium 夹具,240/280/296/320/360/438/900 七档):控件排仍 **252px** 所需、外壳仍 **278px**;自然宽度 290.4(438/900 档)→ 算式不变;七档 `topSpread ≤ 1px`、控件两两相交 **0** 处、`outsideControlsRow` 空、润色钮与发送钮间距固定 **8px**(同一行、同一组)
- 截图:240px 改前→改后 + 438px 改后 + 438px toast 已换新(正文引用与附件 1:1:12 = 12,未引用的旧图已删)

Refs #600, PR #607
This commit is contained in:
2026-10-06 10:57:19 +08:00
parent b7440d8c39
commit 7e036a7199
5 changed files with 38 additions and 18 deletions
+2 -1
View File
@@ -13717,7 +13717,8 @@ details.design-agent-reasoning[open]
嵌在菜单顶部,与模型列表共用同一张卡(`.conversation-model-menu-shell`)。 */
/* 工具条里的「AI 润色 / 恢复原文」挂点(`ResourceReferenceInput` 用 portal 渲染进来):
与 `@` / `+` 同组,排在左组最后一个;空输入态只是不显示按钮,不占额外行。 */
位于**右组发送钮之前**——润色钮与发送钮同为圆角矩形按钮,相邻成组;空输入态只是不显示
按钮,不占额外行。DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`(Tab / 读屏顺序同视觉)。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -266,8 +266,8 @@ export function DirectProjectComposer({
placeholder="描述你的想法,或 @ 引用素材"
submitSuppressed={pickerOpen}
containerRef={composerRootRef}
/* 「AI 润色 / 恢复原文」排进下方工具条:与 `@` / `+` / 模型 / 语音 / 发送 同一行,
空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */
/* 「AI 润色 / 恢复原文」排进下方工具条**发送钮左边**:与发送钮同为圆角矩形按钮、
相邻成组;空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */
actionsPortalTarget={controlsActionsSlot}
/>
<div className="project-chat-composer-controls">
@@ -287,12 +287,6 @@ export function DirectProjectComposer({
onPickFiles={(files) => void uploadFiles(files)}
onOpenReferencePicker={openPicker}
/>
{/* 润色 / 恢复原文的挂点(portal 目标):紧跟在 `+` 之后,与输入类动作同组,
右侧留给"生成类"控件(模型 / 语音 / 发送)。 */}
<span
className="project-chat-composer-controls-actions"
ref={setControlsActionsSlot}
/>
</div>
<div className="project-chat-composer-controls-right">
<ConversationModelSelect
@@ -318,6 +312,13 @@ export function DirectProjectComposer({
}}
onNotice={setVoiceNotice}
/>
{/* 润色 / 恢复原文的挂点(portal 目标):放在**发送钮左边**——它与发送钮同为
圆角矩形按钮,相邻成组更协调;DOM 顺序即 `模型 → 麦克风 → 润色 → 发送`,
所以 Tab 序与读屏顺序与视觉一致。 */}
<span
className="project-chat-composer-controls-actions"
ref={setControlsActionsSlot}
/>
{canStopTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
@@ -1428,11 +1428,26 @@ export function registerChatComposerControlTests() {
expect(referenceTrigger).not.toBeNull();
expect(input).not.toBeNull();
expect(controls?.contains(polish as Node)).toBe(true);
// 润色钮和 `@` / `+` 同组(左组,输入类动作),挂在 `+` 之后。
expect(polish?.parentElement?.parentElement?.className).toContain(
'project-chat-composer-controls-actions',
// 润色钮在**右组、发送钮左边**(与发送同为圆角矩形按钮,相邻成组):
// 挂点父链是 `-controls-right`,DOM 顺序里润色紧挨发送之前(Tab/读屏顺序同视觉)。
const polishButton = polish as HTMLElement | null;
const slot = polishButton?.closest(
'.project-chat-composer-controls-actions',
);
expect(input?.contains(polish as Node)).toBe(false);
expect(slot?.parentElement?.className).toContain(
'project-chat-composer-controls-right',
);
const sendButton = within(surface).getByRole('button', { name: '发送' });
expect(slot?.nextElementSibling).toBe(sendButton);
expect(
Boolean(
polishButton &&
polishButton.compareDocumentPosition(sendButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
'润色钮必须排在发送钮之前',
).toBe(true);
expect(input?.contains(polishButton)).toBe(false);
// 空输入态:工具条之外不再有第二行控件(润色状态行只在有内容时渲染)。
expect(
surface.querySelectorAll(
@@ -505,14 +505,17 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
// 空输入态:组件源码里「AI 润色 / 恢复原文」是渲染到工具条那个挂点里的
// (`actionsPortalTarget`),不再是输入盒里的第二行。
const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8');
const controlsIndex = composerSource.indexOf(
'className="project-chat-composer-controls"',
const rightGroupIndex = composerSource.indexOf(
'className="project-chat-composer-controls-right"',
);
const slotIndex = composerSource.indexOf(
'project-chat-composer-controls-actions',
);
expect(controlsIndex).toBeGreaterThanOrEqual(0);
expect(slotIndex).toBeGreaterThan(controlsIndex);
const submitIndex = composerSource.indexOf('{canStopTurn ? (');
expect(rightGroupIndex).toBeGreaterThanOrEqual(0);
// 挂点在**右组内**、且在发送钮之前(DOM 顺序 `模型 → 麦克风 → 润色 → 发送`,Tab 顺序同视觉)。
expect(slotIndex).toBeGreaterThan(rightGroupIndex);
expect(submitIndex).toBeGreaterThan(slotIndex);
expect(composerSource).toContain(
'actionsPortalTarget={controlsActionsSlot}',
);
@@ -126,7 +126,7 @@
- **现象**:#600 截图里用户输入区域「元素堆叠」——空草稿时「AI 润色」按钮就压在输入内容区右下角(实测与编辑器相交 28×24px),模型控件的失败提示「本地模型服务不可用」直接画在第 3~4 行文字上(70×36px,长草稿时把整段文字盖住);面板越窄越糟:280px 面板下推理档整颗压在「添加文件」上(28×28px),「插入素材引用」与推理档也相交(20×28px)。
- **原因**:两条「靠绝对定位把控件挪出流」的旧写法叠在一起:① `.resource-reference-input-actions` 被写成 `position: absolute; right: 0; bottom: 0`,浮到编辑器右下角(同一份样式早前其实是网格第二行,见文件里 11893 行那条注释,被后写的绝对定位规则盖掉);② `ConversationModelSelect` 的 `[role='alert'] / [role='status']` 是 `bottom: 36px` 的浮层,锚点就在输入盒正下方,提示必然落在文字上。另外 `.project-chat-composer-controls-right` 写死 `flex: 0 0 auto`,宽度不够时既不收缩也不换行,整组向右溢出输入盒(360px 面板下溢出 34px、280px 下 114px)。
- **处理(现行口径)**:`.project-chat-composer.is-direct-codex` 是单列网格(`gap: 8px`);**控件全部集中在唯一一行工具条**——`@` / `+` / 「AI 润色 / 恢复原文」/ 模型 / 语音 / 发送,六颗 `flex-wrap: nowrap` 平铺(润色钮由 `ResourceReferenceInput` 的 `actionsPortalTarget` portal 进工具条,不再自成一行);状态/提示层只在有内容时占行(空输入态第二行为 0 高、行间距为 0,状态自己带 6px 上间距)。窄宽度用 `min-width` 兜(控件排 252px / 外壳 278px)+ 横向溢出可见:**不换行、不堆叠、不重叠**,发送钮仍可点击(夹具 `elementFromPoint` 命中)。模型钮封顶 160px 且名字走省略号(收缩由它承担),左组是固定 28px 动作钮、`flex: 0 0 auto` 不参与收缩。**模型失败提示后来改成走浮层**(见本文件顶部 2026-10-04 / 2026-10-05 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。
- **处理(现行口径)**:`.project-chat-composer.is-direct-codex` 是单列网格(`gap: 8px`);**控件全部集中在唯一一行工具条**——左组 `@` / `+`,右组 模型 / 语音 / **「AI 润色 / 恢复原文」(紧贴发送左侧)** / 发送,六颗 `flex-wrap: nowrap` 平铺(润色钮由 `ResourceReferenceInput` 的 `actionsPortalTarget` portal 进右组发送钮之前的挂点,不再自成一行;它与发送钮同为圆角矩形按钮,相邻成组,DOM / Tab 顺序都是 模型 → 麦克风 → 润色 → 发送);状态/提示层只在有内容时占行(空输入态第二行为 0 高、行间距为 0,状态自己带 6px 上间距)。窄宽度用 `min-width` 兜(控件排 252px / 外壳 278px)+ 横向溢出可见:**不换行、不堆叠、不重叠**,发送钮仍可点击(夹具 `elementFromPoint` 命中)。模型钮封顶 160px 且名字走省略号(收缩由它承担),左组是固定 28px 动作钮、`flex: 0 0 auto` 不参与收缩。**模型失败提示后来改成走浮层**(见本文件顶部 2026-10-04 / 2026-10-05 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。
- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住单行工具条(240 / 280 / 296 / 320 / 360 / 438 / 900 七个宽度下 `flex-wrap` 都是 `nowrap`、控件排 `min-width: 252px`、外壳 `min-width: 278px`)、输入盒最高高度算式(12 盒内上内边距 + 160 编辑器 + 0 输入区行距 + 0 状态行 + 0 输入区下内边距 + 8 输入盒与工具栏行距 + **30 工具栏行高(模型触发钮 30px 撑起来,发送/语音是 28px)** + 12 盒内下内边距 = **222**),以及组件源码里润色钮渲染在工具条挂点(`actionsPortalTarget={controlsActionsSlot}`、挂点在控件排之后);`tests/appSurface/project-development.suite.ts` 钉住 composer 的 `gap: 8px`。真实渲染对照见 #600 PR:同一夹具在 495/438/360/296/280px 面板 × 空 / 长多行 / 队列+提示 四态下,改前 2~4 处相交(含文字被提示盖住),改后 0 处相交、0 处溢出。
- **关联**:`apps/ai-game-creator-shell/src/styles.css`(文件末尾「输入区工具栏与状态提示」区块)、`docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md`。