diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 149e5f7f7..bf158680d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -114,6 +114,13 @@ type ResourceReferenceInputProps = { submitSuppressed?: boolean; /** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */ inputActions?: ReactNode; + /** + * 把「AI 润色 / 恢复原文」这一排**渲染到宿主指定的容器里**(`createPortal`)。 + * + * Direct 输入区把它挪进下方那条工具条,让润色钮与 `@` / `+` / 模型 / 语音 / 发送 同一水平线 + * (否则它会自成一行,窄面板下看起来像"堆叠")。不传时仍然渲染在输入盒内(策划面等保持原样)。 + */ + actionsPortalTarget?: HTMLElement | null; inputRef?: RefObject; /** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */ containerRef?: RefObject; @@ -431,6 +438,7 @@ function ResourceReferenceEditor({ showPolishAction = true, submitSuppressed = false, inputActions, + actionsPortalTarget, composerRef, rootRef, }: ResourceReferenceInputProps & { @@ -842,48 +850,55 @@ function ResourceReferenceEditor({ return () => form.removeEventListener('submit', handleFormSubmit, true); }, [rootRef]); - return ( - <> -
- {inputActions} - {showPolishAction ? ( - <> + /** + * 「输入区操作排」这一块:宿主注入的 `inputActions` + 内置的「AI 润色 / 恢复原文」。 + * 默认渲染在输入盒里;宿主给了 `actionsPortalTarget` 时改渲染到那个容器(Direct 把它放进 + * 下方工具条,与 `@` / `+` / 模型 / 语音 / 发送 同一行)。 + */ + const inputActionsBlock = ( +
+ {inputActions} + {showPolishAction ? ( + <> + + {polishedOriginalText !== null ? ( - {polishedOriginalText !== null ? ( - - ) : null} - - ) : null} -
+ ) : null} + + ) : null} +
+ ); + + return ( + <> + {actionsPortalTarget + ? createPortal(inputActionsBlock, actionsPortalTarget) + : inputActionsBlock} {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} {showPolishAction && diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 797336220..941dd5e10 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11799,8 +11799,11 @@ button.design-workspace-tree__entry:hover, .project-chat-composer.is-direct-codex { display: grid; grid-template-columns: minmax(0, 1fr); - /* 三层(输入内容区 / 工具栏 / 状态提示区)之间靠行间距分开,任何一层都不再用 - 绝对定位去压另一层;`gap` 同时给队列 chip 与输入盒之间留出同一档间距。 */ + /* 单行工具条下限 252 内缩在盒内(左右各 12px 内边距 + 各 1px 边框)→ 外壳最小宽度 + 252 + 24 + 2 = **278**:低于它时不换行、不重叠,内容溢出可见(面板不裁切)。 */ + min-width: 278px; + /* 控件行 / 状态层之间靠行间距分开(状态层没有内容时为 0 高,不占行); + `gap` 同时给队列 chip 与输入盒之间留出同一档间距。 */ gap: 8px; align-items: stretch; position: relative; @@ -11878,17 +11881,20 @@ button.design-workspace-tree__entry:hover, pointer-events: auto; } -/* Direct 控件排:三层布局下允许换行,两轴间距分开写(行 6 / 列 8)。 */ +/* Direct 控件排:**唯一一行控件,永远不换行**(`@` / `+` / AI 润色 / 模型 / 语音 / 发送 平铺)。 + 单行下限 = Σ 控件最小宽度 + 列间距:`@` 28 + `+` 28 + 润色 28 + 模型钮 64(`min-width: 64px`, + 名字走省略号)+ 语音 28 + 发送 28 = 204,加 5 个 8px 列间距 = 244;再留 8px 余量 → **252** + (夹具实测:自然宽度 290.4、下限 244)。低于 252 时不换行、不重叠,内容溢出可见。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 6px 8px; - min-width: 0; + min-width: 252px; pointer-events: auto; } @@ -11917,12 +11923,15 @@ button.design-workspace-tree__entry:hover, min-width: 0; } -/* Direct 左侧动作组(`@` 引用 / `+` 附件)允许换行:窄面板下它和右侧控件组各占一行。 */ +/* Direct 左侧动作组(`@` 引用 / `+` 附件 / AI 润色钮)同样不换行:工具条只有一行。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls-left { - flex-wrap: wrap; + /* 左组是固定宽度的动作钮(`@` / `+` / 润色),不参与收缩:收缩会让它们溢出、 + 压到右侧的模型钮上;要收缩就由右组的模型钮(名字走省略号)承担。 */ + flex: 0 0 auto; + flex-wrap: nowrap; } /* 右侧那一组是**不可再压的控件**(推理档 / 模型 / 语音 / 发送):它原来写死 @@ -12549,6 +12558,7 @@ button.design-workspace-tree__entry:hover, grid-column: 1; align-self: center; min-width: 0; + margin-top: 6px; margin-top: 0; } @@ -13230,7 +13240,8 @@ button.design-workspace-tree__entry:hover, grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; align-content: start; - gap: 6px 8px; + /* 行间距归 0:状态层没有内容时第二行高度为 0,不留幻影行距(间距给状态元素自己)。 */ + gap: 0 8px; } /* 编辑器横跨两列:文字不再被右侧 28px 的操作列长期挤窄。 */ @@ -13255,24 +13266,11 @@ button.design-workspace-tree__entry:hover, grid-column: 1; align-self: center; min-width: 0; + margin-top: 6px; margin-top: 0; overflow-wrap: anywhere; } -.game-workbench-chat - .project-chat-surface.is-direct-codex - .project-chat-composer.is-direct-codex - .resource-reference-input - .resource-reference-input-actions { - grid-row: 2; - grid-column: 2; - position: static; - display: flex; - align-items: center; - justify-content: flex-end; - min-width: 0; -} - /* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。 窄面板里一行塞不下(汇总会被截断、"进行中"还会折成两行),拆成两行后每段都有自己的宽度。 */ .game-workbench-chat @@ -13716,13 +13714,37 @@ details.design-agent-reasoning[open] ④ 思维强度并入模型菜单:底行只留模型触发钮一颗控件,强度区(原生 ``) 嵌在菜单顶部,与模型列表共用同一张卡(`.conversation-model-menu-shell`)。 */ -/* ① 下拉控件:可收缩 + 组内换行;模型名超长走省略号。 */ +/* 工具条里的「AI 润色 / 恢复原文」挂点(`ResourceReferenceInput` 用 portal 渲染进来): + 与 `@` / `+` 同组,排在左组最后一个;空输入态只是不显示按钮,不占额外行。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-actions { + display: inline-flex; + /* 不参与收缩:里面的润色钮是 28px 固定方块,槽位被压窄会让它溢出去压到 `+`。 */ + flex: 0 0 auto; + align-items: center; + min-width: 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-composer.is-direct-codex + .project-chat-composer-controls-actions + .resource-reference-input-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +/* ① 下拉控件:可收缩(模型名超长走省略号);整条工具条不换行,控件排只占一行。 */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex .project-chat-composer-controls .conversation-model-select { - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: flex-end; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 38454c6c5..03665da7a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -133,6 +133,9 @@ export function DirectProjectComposer({ }) { const composerRef = useRef(null); const composerRootRef = useRef(null); + /** 工具条里给「AI 润色 / 恢复原文」的挂点(`ResourceReferenceInput` 用 portal 渲染进来)。 */ + const [controlsActionsSlot, setControlsActionsSlot] = + useState(null); const pickerRef = useRef(null); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); @@ -263,6 +266,9 @@ export function DirectProjectComposer({ placeholder="描述你的想法,或 @ 引用素材" submitSuppressed={pickerOpen} containerRef={composerRootRef} + /* 「AI 润色 / 恢复原文」排进下方工具条:与 `@` / `+` / 模型 / 语音 / 发送 同一行, + 空输入态也不再自带一行(窄面板下那一行看起来像"堆叠")。 */ + actionsPortalTarget={controlsActionsSlot} />
@@ -281,6 +287,12 @@ export function DirectProjectComposer({ onPickFiles={(files) => void uploadFiles(files)} onOpenReferencePicker={openPicker} /> + {/* 润色 / 恢复原文的挂点(portal 目标):紧跟在 `+` 之后,与输入类动作同组, + 右侧留给"生成类"控件(模型 / 语音 / 发送)。 */} +
{ const list = desktopDeclarations(...LIST_SELECTORS); - const { total, actionsRowHeight } = composerMaxHeight(); + const { total, polishButtonHeight } = composerMaxHeight(); - // 输入盒最高高度由真实声明算出来(三层布局,自上而下): - // 盒内上内边距 12 + 编辑器 160 + 输入区行距 6 + 操作排 28 + 输入区下内边距 0 - // + 输入盒与工具栏的行距 8 + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = 256。 - expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28); + // 输入盒最高高度由真实声明算出来(自上而下): + // 盒内上内边距 12 + 编辑器 160 + 输入区行距 0(状态行没内容时为 0 高,不留幻影行距) + // + 状态行 0 + 输入区下内边距 0 + 输入盒与工具栏的行距 8 + // + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = **222**。 + expect(polishButtonHeight, '润色钮在工具条里仍是 28px 方块').toBe(28); expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28); - expect(total).toBe(256); + expect(total).toBe(222); const paddingBottom = paddingBox(list).bottom; // 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式, @@ -444,6 +445,63 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 ); }); + it('工具条只有一行:七个宽度下都不换行,控件排与外壳带单行最小宽度(含 AI 润色钮)', () => { + // 宽度扫描:任何面板宽下工具条都必须 `nowrap`,且样式表里**不再有** Direct 作用域的 + // `flex-wrap: wrap`(窄面板曾经靠换行避免重叠,现在改成 min-width + 溢出可见)。 + const CONTROLS_LEFT = `${COMPOSER} .project-chat-composer-controls-left`; + const CONTROLS_RIGHT = `${COMPOSER} .project-chat-composer-controls-right`; + const CONTROLS_MODEL_SELECT = `${COMPOSER_CONTROLS} .conversation-model-select`; + const WIDTHS = [240, 280, 296, 320, 360, 438, 900] as const; + for (const width of WIDTHS) { + const controls = resolveDeclarations(rules, [COMPOSER_CONTROLS], width); + expect( + declaration(controls, 'flex-wrap'), + `${width}px 下工具条必须保持单行`, + ).toBe('nowrap'); + const left = resolveDeclarations(rules, [CONTROLS_LEFT], width); + expect(declaration(left, 'flex-wrap'), `${width}px 下左组不换行`).toBe( + 'nowrap', + ); + const right = resolveDeclarations(rules, [CONTROLS_RIGHT], width); + expect(right.has('flex-wrap'), '右组本来就不换行').toBe(false); + const modelSelect = resolveDeclarations( + rules, + [CONTROLS_MODEL_SELECT, `${CONTROLS_RIGHT} .conversation-model-select`], + width, + ); + expect(declaration(modelSelect, 'flex-wrap')).toBe('nowrap'); + } + + // 单行下限:控件排 252px(Σ 控件最小宽度 204 + 5×8 间距 = 244,再留 8 余量), + // 外壳 252 + 左右内边距各 12 + 边框各 1 = 278px。夹具实测:自然宽度 290.4。 + const controls = desktopDeclarations(COMPOSER_CONTROLS); + expect(pixelValue(controls, 'min-width')).toBe(252); + const composer = desktopDeclarations( + COMPOSER, + COMPOSER_PLAIN, + CONVERSATION_COMPOSER, + ); + expect( + pixelValue(composer, 'min-width'), + '外壳最小宽度 = 工具条下限 + 左右内边距 + 边框', + ).toBe(278); + + // 空输入态:组件源码里「AI 润色 / 恢复原文」是渲染到工具条那个挂点里的 + // (`actionsPortalTarget`),不再是输入盒里的第二行。 + const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8'); + const controlsIndex = composerSource.indexOf( + 'className="project-chat-composer-controls"', + ); + const slotIndex = composerSource.indexOf( + 'project-chat-composer-controls-actions', + ); + expect(controlsIndex).toBeGreaterThanOrEqual(0); + expect(slotIndex).toBeGreaterThan(controlsIndex); + expect(composerSource).toContain( + 'actionsPortalTarget={controlsActionsSlot}', + ); + }); + it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => { const conversation = mobileDeclarations(CONVERSATION); const list = mobileDeclarations(...LIST_SELECTORS); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5d6301c29..35f4f7b81 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -116,8 +116,8 @@ - **现象**:#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 润色 / 恢复原文」操作排),操作排 `position: static` 回到行里;工具栏允许换行、右侧组改 `flex: 1 1 auto`,模型钮封顶 160px 且名字走省略号(收缩由它承担,推理档 `flex: 0 0 auto` 不参与收缩,避免把「默认」截成半个字)。**模型失败提示后来改成走浮层**(见本文件顶部 2026-10-04 / 2026-10-05 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。 -- **判据/取证**:`apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts` 用层叠求值钉住三层几何与操作排 `grid-row: 2 / position: static`、输入盒最高高度算式(12 盒内上内边距 + 160 编辑器 + 6 输入区行距 + 28 操作排 + 0 输入区下内边距 + 8 输入盒与工具栏行距 + **30 工具栏行高(模型触发钮 30px 撑起来,发送/语音是 28px)** + 12 盒内下内边距 = **256**);`tests/appSurface/project-development.suite.ts` 钉住 composer 的 `gap: 8px`。真实渲染对照见 #600 PR:同一夹具在 495/438/360/296/280px 面板 × 空 / 长多行 / 队列+提示 四态下,改前 2~4 处相交(含文字被提示盖住),改后 0 处相交、0 处溢出。 +- **处理(现行口径)**:`.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 两条):控件排里既不留常驻红字、也不留常驻状态文字——「控件排内独占一行」那次快照已退役,行高回到由模型触发钮决定。 +- **判据/取证**:`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`。 ## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过