diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b0e8757a3..8bc7b0bf7 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -9831,9 +9831,12 @@ iframe.preview-frame { /* Direct Codex composer: keep the editor and its actions in one vertical structure instead of positioning each control independently. */ -/* 输入区在对话框(`.project-supervisor-message-list` 那只铺满会话区的盒子)内部: - 左右下三边各内缩 12px,圆角收成与外框同一档,读起来是"框内的输入块"而不是贴在 - 对话框下面、四边描边与下边竖线连成一体的第二只盒子。 */ +/* 输入区在对话框(`.project-supervisor-message-list` 那只铺满会话区的盒子)内部, + 四边各内缩 12px:左右下三边显式钉住,上边由 `max-height: calc(100% - 24px)` + (24px = 上下各 12px)兜底,所以输入区整块落在外框之内,四条边都不与外框描边重合, + 底边还留出 12px 边距(外框底边在下面那条"最终几何"规则里钉的是 0)。 + 圆角收成与外框同一档,读起来是"框内的输入块",而不是贴在对话框下面、四边描边与 + 下边竖线连成一体的第二只盒子。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex { @@ -9846,6 +9849,7 @@ iframe.preview-frame { right: 12px; bottom: 12px; left: 12px; + max-height: calc(100% - 24px); z-index: 5; padding: 8px 12px; border: 1px solid var(--platform-surface-border); @@ -10008,10 +10012,68 @@ iframe.preview-frame { color: var(--platform-button-primary-text); } +/* 对话框盒子的最终几何,故意放在文件靠后的位置:它与上面那条同选择器同权重 + (`.game-workbench-chat .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list`),按"后写胜出"把两处会顶掉它的规则压回去—— + 一是 `:has(.plan-gdd-surface, .planning-lane-runtime-strip)` 给列表留的 + `padding-bottom: 12px`,二是下面窄屏块之前可能出现的任何底边偏移。 + 这里曾经是一条 `bottom: 156px`("列表在上、输入区在下"那版两盒布局的残留:给下面那只 + 浮起来的输入区预留高度),它把上面那条 `bottom: 0` 悄悄顶掉,外框底边落在输入区腰上、 + 输入区下半截露在框外——用户看到的就是"外框底边与输入区压在一起、没把输入区包住"。 + 所以:外框四边一律贴会话区(top/right/bottom/left 全 0),它才是把输入区包住的那只框; + 输入区靠自己的 12px 内缩与它留出边距,两条边不会再重合。 + 要改列表底边只改这一段,不要再在别处写第二条同选择器规则。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-message-list { - bottom: 156px; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding-bottom: 256px; + scroll-padding-bottom: 256px; +} + +/* 窄屏(≤760px)这一列是"消息列表 + 输入区"的纵向堆叠,输入区不再浮在列表里,所以外框 + 改画在两者共同的父节点 `.project-supervisor-conversation` 上:它同时把列表和输入区圈进 + 来。列表自己那只框在窄屏撤掉(否则框里再套一只框),输入区回到文档流,靠外框的 12px + 内边距与四条边框各留出一圈边距——任一条边都不与外框重合,内容随列高增长也不会溢出。 + 宽屏那套留白算式(256 = 12px 内缩 + 228px 输入区最高高度 + 16px 间距)只服务于 + "输入区浮在列表之上"这个模型;窄屏输入区在流内,天然不会被消息盖住,所以列表底边只留 + 12px 与输入区顶边分开。 */ +@media (max-width: 760px) { + .game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-conversation { + padding: 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: var(--platform-input-fill); + } + + .game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-message-list { + padding-bottom: 12px; + scroll-padding-bottom: 12px; + border: 0; + border-radius: 0; + background: transparent; + } + + /* 输入区回到文档流。这条选择器比上面那条 `.project-supervisor-composer.is-direct-codex` + 多带一层 `.project-supervisor-conversation`,权重比它高,窄屏才能把它顶回来—— + 原来窄屏那条(同权重、写得更早)其实一直是被顶掉的那条。 */ + .game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-conversation + .project-supervisor-composer.is-direct-codex { + position: relative; + right: auto; + bottom: auto; + left: auto; + max-height: none; + } } .resource-reference-input-editor::-webkit-scrollbar { 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 d6b0cb8cd..2f0652a8c 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 @@ -5046,7 +5046,8 @@ export function registerProjectWorkbenchFoundationTests() { /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\.is-direct-codex\s*\{([^}]*)\}/s, )?.[1] ?? ''; expect(composerRule).not.toBe(''); - // 输入区四边内缩:不贴外框(左右下三边各 12px),也不与外框重叠。 + // 输入区四边内缩:左右下三边各 12px 显式钉住,上边由 `max-height: calc(100% - 24px)` + // 兜底,四条边都不与外框描边重合,底边还留出 12px 边距。 const inset = styleNumber(composerRule, 'right'); expect(inset).toBeGreaterThan(0); expect(styleNumber(composerRule, 'left')).toBe(inset); @@ -5058,19 +5059,23 @@ export function registerProjectWorkbenchFoundationTests() { ); expect(composerRule).toContain('background: var(--platform-input-fill);'); - // 消息列表给输入区留出位置:底边留白必须 ≥ 输入区最高高度 + 内缩。 - // 输入区最高高度 = 编辑器 max-height(140) + 操作排(30) + 网格间隙(8) + 输入区 - // padding-bottom(4) + 自己的上下 padding(16) + 控制排(30 + 2) ≈ 230。 + // 消息列表给输入区留出位置:底边留白 = 内缩 + 输入区最高高度 + 间距。 + // 输入区最高高度 = 编辑器 max-height(140) + 输入框行距(8) + 操作排 28px 方钮(28) + // + 输入框下内边距(4) + 输入区自己上下内边距(8 × 2) + 操作条(2 + 30 方钮) = 228; + // 256 = 12px 内缩 + 228 + 16px 间距。这里按层叠"第一条"取值,改动后务必同时跑 + // tests/chatDialogFrameLayout.test.ts——那条用例按层叠生效值验同一组几何(含窄屏), + // 后面再写一条同选择器的规则顶掉这里,只有那条会失败。 const composerClearance = styleNumber( messageListRule, 'scroll-padding-bottom', ); - expect(composerClearance).toBeGreaterThanOrEqual(inset + 230); + expect(composerClearance).toBeGreaterThanOrEqual(inset + 228); + expect(composerClearance).toBe(inset + 228 + 16); const messageListPadding = /padding:\s*18px 20px (\d+)px;/u.exec(messageListRule)?.[1] ?? ''; expect(Number(messageListPadding)).toBe(composerClearance); - // 编辑器与操作排的高度上限就是上面那个 230 的来源;改这里就必须同步改留白。 + // 编辑器高度上限就是上面那个 228 的来源之一;改这里就必须同步改留白。 const editorRule = styles.match( /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s, diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts new file mode 100644 index 000000000..80118a0fb --- /dev/null +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -0,0 +1,568 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const STYLES_PATH = resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/styles.css', +); +const VIEW_PATH = resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx', +); + +/* ============================================================ + 声明级层叠求值器。 + jsdom 不加载 styles.css,所以"外框有没有把输入区包住"这类几何只能在声明上验; + 但只读"第一条匹配到的规则"会被后面的同权重规则悄悄顶掉——本用例要防的就是这种事 + (`bottom: 156px` 那条就是这样把上一轮修的 `bottom: 0` 变成死声明的), + 所以这里按真实层叠(媒体查询是否命中 -> 特异性 -> 源码顺序)算出最终生效的值。 + ============================================================ */ + +type StyleRule = { + selectors: string[]; + declarations: Map; + media: string | null; + order: number; +}; + +function parseStyleSheet(css: string): StyleRule[] { + const rules: StyleRule[] = []; + const source = css.replace(/\/\*[\s\S]*?\*\//gu, (comment) => + comment.replace(/[^\n]/gu, ' '), + ); + + const pushDeclarationBlock = ( + selectorText: string, + body: string, + media: string | null, + ) => { + const selectors = splitSelectorList(selectorText); + if (selectors.length === 0) { + return; + } + const declarations = new Map(); + for (const chunk of body.split(';')) { + const separator = chunk.indexOf(':'); + if (separator < 0) { + continue; + } + const property = chunk.slice(0, separator).trim(); + const value = chunk + .slice(separator + 1) + .trim() + .replace(/\s+/gu, ' '); + if (property) { + declarations.set(property, value); + } + } + rules.push({ selectors, declarations, media, order: rules.length }); + }; + + const readBlock = (text: string, start: number) => { + let depth = 1; + let index = start; + while (index < text.length && depth > 0) { + if (text[index] === '{') { + depth += 1; + } else if (text[index] === '}') { + depth -= 1; + } + index += 1; + } + return { body: text.slice(start, index - 1), end: index }; + }; + + let cursor = 0; + let buffer = ''; + while (cursor < source.length) { + const char = source[cursor]!; + if (char === '{') { + const prelude = buffer.trim().replace(/\s+/gu, ' '); + buffer = ''; + const { body, end } = readBlock(source, cursor + 1); + cursor = end; + if (prelude.startsWith('@media')) { + // 媒体查询体内只陈述"顶层选择器 + 声明",这里就够了:查询用的选择器都在顶层。 + let inner = 0; + let innerBuffer = ''; + while (inner < body.length) { + if (body[inner] === '{') { + const innerSelector = innerBuffer.trim().replace(/\s+/gu, ' '); + innerBuffer = ''; + const { body: innerBody, end: innerEnd } = readBlock( + body, + inner + 1, + ); + pushDeclarationBlock(innerSelector, innerBody, prelude); + inner = innerEnd; + continue; + } + innerBuffer += body[inner]; + inner += 1; + } + continue; + } + if (prelude.startsWith('@')) { + continue; + } + pushDeclarationBlock(prelude, body, null); + continue; + } + if (char === '}') { + buffer = ''; + cursor += 1; + continue; + } + buffer += char; + cursor += 1; + } + + return rules; +} + +function splitSelectorList(selectorText: string): string[] { + const selectors: string[] = []; + let depth = 0; + let current = ''; + for (const char of selectorText) { + if (char === '(' || char === '[') { + depth += 1; + } else if (char === ')' || char === ']') { + depth -= 1; + } + if (char === ',' && depth === 0) { + if (current.trim()) { + selectors.push(current.trim().replace(/\s+/gu, ' ')); + } + current = ''; + continue; + } + current += char; + } + if (current.trim()) { + selectors.push(current.trim().replace(/\s+/gu, ' ')); + } + return selectors; +} + +/** 特异性:只区分 id / 类(含属性与伪类)/ 元素,够排序本文件里的选择器。 */ +function specificity(selector: string): number { + let rest = selector; + let classLike = 0; + // `:has(...)` 的权重按它参数里最高的那条选择器算,这里等价于再加一个类。 + const hasParts = rest.match(/:has\([^)]*\)/gu) ?? []; + for (const part of hasParts) { + classLike += 1; + const inner = Math.max( + ...splitSelectorList(part.slice(5, -1)).map((one) => specificity(one)), + ); + classLike += Math.floor(inner / 100) % 100; + } + rest = rest.replace(/:has\([^)]*\)/gu, ' '); + const ids = (rest.match(/#[\w-]+/gu) ?? []).length; + classLike += (rest.match(/\.[\w-]+/gu) ?? []).length; + classLike += (rest.match(/\[[^\]]*\]/gu) ?? []).length; + classLike += (rest.match(/:(?!:)[\w-]+/gu) ?? []).length; + const types = + (rest.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/gu) ?? []).length + + (rest.match(/::[\w-]+/gu) ?? []).length; + return ids * 10000 + classLike * 100 + types; +} + +function mediaMatches(media: string, viewportWidth: number): boolean { + const conditions = media.match(/\((?:min|max)-width:\s*\d+px\)/gu) ?? []; + if (conditions.length === 0) { + // 只认识宽度条件:命中了别的媒体特性(例如 prefers-reduced-motion)就说明这条规则 + // 的生效与否不是本用例能判定的,直接报错,避免悄悄算错一个几何值。 + throw new Error(`测试求值器不认识这个媒体查询:${media}`); + } + for (const condition of conditions) { + const parsed = /\((min|max)-width:\s*(\d+)px\)/u.exec(condition)!; + const limit = Number(parsed[2]); + if (parsed[1] === 'min' && viewportWidth < limit) { + return false; + } + if (parsed[1] === 'max' && viewportWidth > limit) { + return false; + } + } + return true; +} + +/** + * 按层叠算出元素最终生效的声明。 + * `elementSelectors` 是这个元素在 DOM 上会命中的全部选择器(含媒体查询里那几条)。 + */ +function resolveDeclarations( + rules: StyleRule[], + elementSelectors: readonly string[], + viewportWidth: number, +): Map { + const winners = new Map(); + for (const rule of rules) { + const matched = rule.selectors + .filter((selector) => elementSelectors.includes(selector)) + .map((selector) => specificity(selector)); + if (matched.length === 0) { + continue; + } + if (rule.media && !mediaMatches(rule.media, viewportWidth)) { + continue; + } + const rank: [number, number] = [Math.max(...matched), rule.order]; + for (const [property, value] of rule.declarations) { + const current = winners.get(property); + if ( + !current || + rank[0] > current.rank[0] || + (rank[0] === current.rank[0] && rank[1] > current.rank[1]) + ) { + winners.set(property, { value, rank }); + } + } + } + return new Map( + Array.from(winners, ([property, winner]) => [property, winner.value]), + ); +} + +function declaration( + declarations: Map, + property: string, +): string { + const value = declarations.get(property); + expect(value, `缺少生效声明 ${property}`).toBeDefined(); + return value!; +} + +function lengthPx(rawValue: string, label: string): number { + const value = rawValue.trim(); + // CSS 里 0 可以不带单位,其余一律要求 px(本文件不写 rem/em 几何)。 + if (value === '0') { + return 0; + } + const match = /^(-?\d+(?:\.\d+)?)px$/u.exec(value); + expect(match, `${label} 不是像素值:${value}`).not.toBeNull(); + return Number(match![1]); +} + +function pixelValue( + declarations: Map, + property: string, +): number { + return lengthPx(declaration(declarations, property), property); +} + +/** 展开 `padding`:长写优先(后面的长写会覆盖简写),没有长写再拆简写。 */ +function paddingBox(declarations: Map) { + const sides: Array<['top' | 'right' | 'bottom' | 'left', number]> = [ + ['top', 0], + ['right', 0], + ['bottom', 0], + ['left', 0], + ]; + const longhand = new Map(); + for (const [side] of sides) { + if (declarations.has(`padding-${side}`)) { + longhand.set(side, pixelValue(declarations, `padding-${side}`)); + } + } + const shorthand = declarations.get('padding'); + const shorthandValues = new Map(); + if (shorthand) { + const parts = shorthand.split(' ').filter(Boolean); + const value = (index: number) => { + const part = parts[index] ?? parts[parts.length - 1] ?? parts[0]; + return lengthPx(part!, `padding 简写 ${shorthand}`); + }; + if (parts.length === 1) { + const only = value(0); + for (const [side] of sides) { + shorthandValues.set(side, only); + } + } else if (parts.length === 2) { + shorthandValues.set('top', value(0)); + shorthandValues.set('bottom', value(0)); + shorthandValues.set('right', value(1)); + shorthandValues.set('left', value(1)); + } else if (parts.length === 3) { + shorthandValues.set('top', value(0)); + shorthandValues.set('right', value(1)); + shorthandValues.set('bottom', value(2)); + shorthandValues.set('left', value(1)); + } else { + shorthandValues.set('top', value(0)); + shorthandValues.set('right', value(1)); + shorthandValues.set('bottom', value(2)); + shorthandValues.set('left', value(3)); + } + } + const resolved = {} as Record; + for (const [side] of sides) { + const value = longhand.get(side) ?? shorthandValues.get(side); + expect(value, `缺少生效声明 padding-${side}`).toBeDefined(); + resolved[side] = value!; + } + return resolved as { + top: number; + right: number; + bottom: number; + left: number; + }; +} + +/* ============================================================ + 选择器身份 + ============================================================ */ + +const CHAT = '.game-workbench-chat .project-supervisor-surface.is-direct-codex'; +const CONVERSATION = `${CHAT} .project-supervisor-conversation`; +const MESSAGE_LIST = `${CHAT} .project-supervisor-message-list`; +const COMPOSER = `${CHAT} .project-supervisor-composer.is-direct-codex`; +const COMPOSER_PLAIN = `${CHAT} .project-supervisor-composer`; +const CONVERSATION_COMPOSER = `${CONVERSATION} .project-supervisor-composer.is-direct-codex`; +const COMPOSER_INPUT = `${COMPOSER} .resource-reference-input`; +const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`; +const COMPOSER_EDITOR = `${COMPOSER_PLAIN} .resource-reference-input-editor`; +const COMPOSER_CONTROLS = `${COMPOSER} .project-supervisor-composer-controls`; +const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-supervisor-submit-button`; +const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`; +// 策划链在会话列里额外挂了一条规划面/窄条时,列表会命中这条 `:has(...)` 规则—— +// 它同样声明了 `padding-bottom`,正是上一轮把留白改回 12px 的那种隐患。 +const LIST_WITH_PLAN_SURFACE = `.game-workbench-chat .project-supervisor-conversation:has(.plan-gdd-surface, .planning-lane-runtime-strip) .project-supervisor-message-list`; + +const DESKTOP = 1440; +const MOBILE = 390; + +const styles = readFileSync(STYLES_PATH, 'utf8'); +const rules = parseStyleSheet(styles); + +function desktopDeclarations(...elementSelectors: string[]) { + return resolveDeclarations(rules, elementSelectors, DESKTOP); +} + +function mobileDeclarations(...elementSelectors: string[]) { + return resolveDeclarations(rules, elementSelectors, MOBILE); +} + +/** 留白算术里的每一个常量都必须来自文件里真实生效的声明。 */ +function composerMaxHeight() { + const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN); + const input = desktopDeclarations( + COMPOSER_INPUT, + COMPOSER_INPUT_PLAIN, + '.resource-reference-input', + ); + const editor = desktopDeclarations( + COMPOSER_EDITOR, + '.resource-reference-input-editor', + ); + const actions = desktopDeclarations( + INPUT_ACTIONS, + '.resource-reference-input-actions', + ); + const polishButton = desktopDeclarations( + `${INPUT_ACTIONS} .resource-reference-input-polish`, + '.resource-reference-input-polish', + ); + const controls = desktopDeclarations(COMPOSER_CONTROLS); + const submit = desktopDeclarations(COMPOSER_SUBMIT); + + const editorMaxHeight = pixelValue(editor, 'max-height'); + const actionsRowHeight = pixelValue(polishButton, 'height'); + const inputRowGap = pixelValue(input, 'gap'); + const inputPaddingBottom = paddingBox(input).bottom; + const composerPaddingTop = paddingBox(composer).top; + const composerPaddingBottom = paddingBox(composer).bottom; + const controlsRowHeight = + pixelValue(controls, 'padding-top') + pixelValue(submit, 'height'); + + expect( + pixelValue(editor, 'min-height'), + '编辑器最小高度是留白算式的下界,改了要同步改留白', + ).toBe(96); + // 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height, + // 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。 + expect(actions.has('height')).toBe(false); + expect(actions.has('min-height')).toBe(false); + + return { + actionsRowHeight, + total: + editorMaxHeight + + inputRowGap + + actionsRowHeight + + inputPaddingBottom + + composerPaddingTop + + composerPaddingBottom + + controlsRowHeight, + }; +} + +describe('陶泥儿对话区:外框完整包住输入区', () => { + it('外框四边贴会话区,输入区四边都在外框之内且不重合(宽屏)', () => { + const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); + const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN); + + // 先看不重合关系再钉绝对位移:底边必须"外框在外、输入区在内",留白 > 0。 + // (上一轮的残留 bug 就是外框底边被另一条 `bottom: 156px` 顶到输入区腰上。) + const frameBottom = pixelValue(list, 'bottom'); + const frameTop = pixelValue(list, 'top'); + const frameLeft = pixelValue(list, 'left'); + const frameRight = pixelValue(list, 'right'); + const composerBottom = pixelValue(composer, 'bottom'); + const composerLeft = pixelValue(composer, 'left'); + const composerRight = pixelValue(composer, 'right'); + + const inset = composerBottom; + expect(inset, '输入区必须与外框底边留出可见边距').toBeGreaterThan(0); + expect( + composerBottom - frameBottom, + '外框底边必须严格低于输入区底边', + ).toBeGreaterThan(0); + expect( + composerLeft - frameLeft, + '外框左边必须在输入区左边之外', + ).toBeGreaterThan(0); + expect( + composerRight - frameRight, + '外框右边必须在输入区右边之外', + ).toBeGreaterThan(0); + expect(pixelValue(composer, 'right')).toBe(inset); + expect(composerLeft - frameLeft).toBe(inset); + expect(composerRight - frameRight).toBe(inset); + + // 外框四边就是会话区那只盒子——它才是"对话框"。 + expect(frameTop).toBe(0); + expect(frameRight).toBe(0); + expect(frameBottom).toBe(0); + expect(frameLeft).toBe(0); + + // 上边不钉死(底边锚定、向上生长),由 max-height 兜住:任何情况下上边至少离外框 12px。 + expect(declaration(composer, 'position')).toBe('absolute'); + expect(composer.has('top')).toBe(false); + expect(declaration(composer, 'max-height')).toBe( + `calc(100% - ${inset * 2}px)`, + ); + + // 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。 + expect(declaration(composer, 'border')).toContain('1px solid'); + expect(declaration(composer, 'background')).toContain( + 'var(--platform-input-fill)', + ); + }); + + it('消息列表底部留白 = 输入区内缩 + 输入区最高高度 + 间距,且与 scroll-padding 同值', () => { + const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); + const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN); + const { total, actionsRowHeight } = composerMaxHeight(); + + const inset = pixelValue(composer, 'bottom'); + const gap = 16; + const paddingBottom = pixelValue(list, 'padding-bottom'); + + // 输入区最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4 + // + 输入区上下内边距 16 + 操作条(2 + 30 方钮)= 228。 + expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算术式').toBe(28); + expect(total).toBe(228); + + // 留白 ≥ 输入区最高高度 + 内缩:滚到底时最后一条消息不会被输入区盖住。 + expect(paddingBottom).toBeGreaterThanOrEqual(inset + total); + // 且留白就该等于这条算术式,不允许多出一个"来历不明"的常量。 + expect(paddingBottom).toBe(inset + total + gap); + expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom); + }); + + it('窄屏把外框交给会话列,输入区回到文档流,四周仍有一圈边距(390px)', () => { + const conversation = mobileDeclarations(CONVERSATION); + const list = mobileDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); + const composer = mobileDeclarations( + COMPOSER, + COMPOSER_PLAIN, + CONVERSATION_COMPOSER, + ); + + // 外框画在列表与输入区共同的父节点上,用的还是那套 token。 + const framePadding = paddingBox(conversation); + const inset = framePadding.top; + expect(inset, '窄屏外框必须给输入区留出可见边距').toBeGreaterThan(0); + expect(framePadding.right).toBe(inset); + expect(framePadding.bottom).toBe(inset); + expect(framePadding.left).toBe(inset); + expect(declaration(conversation, 'border')).toContain( + 'var(--platform-subpanel-border)', + ); + expect(declaration(conversation, 'background')).toContain( + 'var(--platform-input-fill)', + ); + expect(declaration(conversation, 'border-radius')).toBe('12px'); + + // 列表在窄屏不再自画一只框:框里不套框,也就不会出现两条描边压在一起。 + const listFrame = list.get('border'); + expect(listFrame).toBeDefined(); + expect(listFrame).not.toContain('1px'); + expect(declaration(list, 'background')).toBe('transparent'); + expect(declaration(list, 'position')).toBe('static'); + + // 输入区回到文档流:它是外框的子节点,四边由外框内边距让出,不会再盖住消息。 + expect(declaration(composer, 'position')).toBe('relative'); + expect(declaration(composer, 'bottom')).toBe('auto'); + expect(declaration(composer, 'left')).toBe('auto'); + expect(declaration(composer, 'right')).toBe('auto'); + expect(declaration(composer, 'max-height')).toBe('none'); + // 流内元素只与列表上下相邻,列表底边留 12px 与它分开。 + expect(pixelValue(list, 'padding-bottom')).toBe(inset); + }); + + it('输入区仍落在对话框外框节点的子树里,交互控件原位', () => { + const source = readFileSync(VIEW_PATH, 'utf8'); + const conversationOpen = source.indexOf( + '
', + ); + expect(conversationOpen, '会话列容器缺失').toBeGreaterThanOrEqual(0); + + // 用 div 配对找出会话列容器的闭合位置(属性里的箭头函数不影响
计数)。 + const before = source.slice(conversationOpen); + const divOpeners = (before.match(/])/gu) ?? []).length; + expect(divOpeners).toBeGreaterThan(0); + let depth = 0; + const cursor = conversationOpen; + let conversationEnd = -1; + const tokenPattern = /])|<\/div>/gu; + tokenPattern.lastIndex = conversationOpen; + let token = tokenPattern.exec(source); + while (token) { + depth += token[0] === '
' ? -1 : 1; + if (depth === 0) { + conversationEnd = token.index + token[0].length; + break; + } + token = tokenPattern.exec(source); + } + expect(conversationEnd, '会话列容器没有闭合').toBeGreaterThan( + conversationOpen, + ); + + const conversationSource = source.slice(conversationOpen, conversationEnd); + expect(conversationSource).toContain('project-supervisor-message-list'); + expect(conversationSource).toContain('project-supervisor-composer'); + + const composerSource = conversationSource.slice( + conversationSource.indexOf('') + ''.length, + ); + // 编辑器、@ 引用、AI 润色、模型选择、提交按钮全在这棵子树里,位置只由样式挪。 + expect(composerSource).toContain('ResourceReferenceInput'); + expect(composerSource).toContain('project-supervisor-composer-controls'); + expect(composerSource).toContain('project-supervisor-reference-trigger'); + expect(composerSource).toContain('ConversationModelSelect'); + expect(composerSource).toContain('{submitButton}'); + expect(composerSource).toContain('onSubmit={'); + // 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。 + expect(source).toContain('className="project-supervisor-submit-button"'); + }); +});