\{walletEntry\}<\/div>/g,
);
+ expect(chatWalletSlots).toHaveLength(1);
+ expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/);
expect(projectDevelopmentSource).toMatch(
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s,
);
@@ -6077,36 +6121,25 @@ 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 显式钉住,上边由 `max-height: calc(100% - 24px)`
- // 兜底,四条边都不与外框描边重合,底边还留出 12px 边距。
- const inset = styleNumber(composerRule, 'right');
- expect(inset).toBeGreaterThan(0);
- expect(styleNumber(composerRule, 'left')).toBe(inset);
- expect(styleNumber(composerRule, 'bottom')).toBe(inset);
- expect(composerRule).toContain('position: absolute;');
- // 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。
+ // 输入盒在文档流里(Codex 三段式的第三段):不再用 right/bottom/left 内缩钉在
+ // 消息列表之上,四边一律归 0;它是整块面板里唯一有边框的容器。
+ expect(styleNumber(composerRule, 'right')).toBe(0);
+ expect(styleNumber(composerRule, 'left')).toBe(0);
+ expect(styleNumber(composerRule, 'bottom')).toBe(0);
+ expect(composerRule).toContain('position: relative;');
expect(composerRule).toContain(
'border: 1px solid var(--platform-surface-border);',
);
expect(composerRule).toContain('background: var(--platform-input-fill);');
- // 消息列表给输入区留出位置:底边留白 = 内缩 + 输入区最高高度 + 间距。
- // 输入区最高高度 = 编辑器 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 + 228);
- expect(composerClearance).toBe(inset + 228 + 16);
- const messageListPadding =
- /padding:\s*18px 20px (\d+)px;/u.exec(messageListRule)?.[1] ?? '';
- expect(Number(messageListPadding)).toBe(composerClearance);
+ // 输入盒在文档流里,消息列表不再需要给它留位置:底边归 0 留白,滚动到底
+ // 不会多出一段空白。具体几何(含窄屏)由 tests/chatDialogFrameLayout.test.ts 按
+ // 层叠生效值验证;这里钉住"旧模型的数字没有被写回来"。
+ expect(styleNumber(messageListRule, 'scroll-padding-bottom')).toBe(0);
+ expect(messageListRule).toContain('padding-bottom: 0;');
+ expect(messageListRule).toContain('flex: 1 1 auto;');
- // 编辑器高度上限就是上面那个 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,
@@ -6150,7 +6183,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
expect(userMessageRule).toContain('color: var(--platform-text-base);');
- expect(processCardRules).toHaveLength(1);
+ // 执行过程卡有两条同选择器规则:第一条是几何(`width: 100%`),后面那条是 Codex 暖色
+ // 皮肤下的配色(把基础规则的绿系换成中性描边 + 暖底)。承重的是几何那条。
+ expect(processCardRules.length).toBeGreaterThanOrEqual(1);
expect(processCardRules[0]).toContain('width: 100%;');
});
@@ -6674,16 +6709,16 @@ export function registerProjectSupervisorSurfaceTests() {
expect(anchor).toMatch(/flex:\s*0 1 auto/u);
expect(anchor).toMatch(/min-width:\s*0/u);
- // 输入区高度变化不得推动同排操作元素:composer 是底边锚定的两行网格,输入区占第一
- // 行、控制排占第二行;输入区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍钉在
- // composer 底边(`bottom: 12px`),发送钮与模型选择钮不动。
+ // 输入盒高度变化不得推动同排操作元素:composer 是三行网格(信息标签 / 文本区 /
+ // 控制排),文本区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍在同一行的
+ // 下面一行(发送钮与模型选择钮不动)。它不再靠 `position: absolute` 底边锚定。
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);
+ expect(composer).toMatch(/position:\s*relative/u);
+ expect(composer).toMatch(/grid-template-rows:\s*auto auto auto/u);
+ expect(styleNumber(composer, 'bottom')).toBe(0);
// 控制排是单行 flex、不换行:窄屏下靠可压缩的模型选择钮(flex 0 1 auto + min-width 0)
// 收窄,而不是把模型选择钮/发送钮挤到第二行。
const controls = styleRuleBody(
@@ -6692,13 +6727,13 @@ export function registerProjectSupervisorSurfaceTests() {
);
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);
+ expect(controlsButtons).toMatch(/flex:\s*0 0 28px/u);
});
it('bounds the model dropdown height so a long catalog cannot cover the composer', () => {
@@ -7996,6 +8031,150 @@ export function registerProjectSupervisorSurfaceTests() {
).toHaveLength(policyReadCountBeforeChat + 1);
});
+ it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => {
+ // 空态只在 direct-codex 面板、且没有任何消息/流式回复/待确认时出现。真实项目打开时
+ // App 总会先放一条默认问候(`createDefaultChatMessages`),所以这里直接挂面板本体,
+ // 把「空消息」这一格单独钉住。
+ const projectPath = '/tmp/launcher-codex-panel-game';
+ const view = render(
+ React.createElement(ProjectSupervisorView, {
+ activeVersionId: null,
+ chatInput: '',
+ chatReferences: [],
+ chatProjectAssets: [],
+ composerRef: createRef(),
+ directCodex: true,
+ directStatus: null,
+ directProcessDetail: '',
+ directProcessKey: '',
+ hiddenConversationCount: 0,
+ messagesRef: createRef(),
+ needsUserInput: false,
+ onCancelConfirmation: vi.fn(),
+ onCancelPendingCommand: vi.fn(),
+ onChatInputChange: vi.fn(),
+ onConfirmConfirmation: vi.fn(),
+ onConfirmPendingCommand: vi.fn(),
+ onScroll: vi.fn(),
+ onShowEarlierMessages: vi.fn(),
+ onSubmit: vi.fn(),
+ pendingConfirmation: null,
+ pendingCommand: null,
+ projectPath,
+ transientReply: '',
+ visibleMessages: [],
+ visibleProfessionalAgentCards: [],
+ workspaceStatus: '等待指令',
+ planGddState: createPlanGddStateView(),
+ planGddHydrateBusy: false,
+ planGddDecisionBusy: false,
+ planGddError: null,
+ onPlanGddRefresh: vi.fn(),
+ onPlanGddDecision: vi.fn(),
+ runtime: null,
+ error: '',
+ runtimeByAgentId: {},
+ controlBusy: false,
+ professionalResultsByAgentId: {},
+ onToolAction: vi.fn(),
+ onSupervisorRetry: vi.fn(),
+ onProfessionalToolAction: vi.fn(),
+ onProfessionalRetry: vi.fn(),
+ onUserInput: vi.fn(),
+ }),
+ );
+
+ const supervisorSurface = screen.getByLabelText('陶泥儿项目对话');
+ // 空态:主标题 + 一句短指令副标题(面板里不写功能说明),整体落在消息区里居中。
+ const emptyState = supervisorSurface.querySelector(
+ '.project-supervisor-empty-state',
+ );
+ expect(emptyState).not.toBeNull();
+ expect(
+ within(emptyState as HTMLElement).getByText('你想让陶泥儿做什么游戏?'),
+ ).not.toBeNull();
+ expect(
+ within(emptyState as HTMLElement).getByText(
+ '告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
+ ),
+ ).not.toBeNull();
+ expect(
+ supervisorSurface
+ .querySelector('.project-supervisor-message-list')
+ ?.contains(emptyState),
+ ).toBe(true);
+ // 顶栏:状态文案带 aria-live,旧的头部标题与审批按钮都不在。
+ expect(
+ within(supervisorSurface).getByText('等待指令', {
+ selector: '[aria-live="polite"]',
+ }),
+ ).not.toBeNull();
+ expect(within(supervisorSurface).queryByText('与陶泥儿的对话')).toBeNull();
+ expect(
+ within(supervisorSurface).queryByRole('button', { name: /审批配置/ }),
+ ).toBeNull();
+ // 输入盒三段式:信息标签行 / 文本区 / 控制排。项目名 + 本地两个标签,`+` 与 `@` 都在。
+ const composer = supervisorSurface.querySelector(
+ 'form.project-supervisor-composer',
+ );
+ expect(composer).not.toBeNull();
+ const context = composer?.querySelector(
+ '.project-supervisor-composer-context',
+ );
+ expect(
+ within(context as HTMLElement).getByText('launcher-codex-panel-game'),
+ ).not.toBeNull();
+ expect(within(context as HTMLElement).getByText('本地')).not.toBeNull();
+ expect(
+ within(composer as HTMLElement).getByRole('button', {
+ name: '添加素材引用',
+ }),
+ ).not.toBeNull();
+ expect(
+ within(composer as HTMLElement).getByRole('button', {
+ name: '插入素材引用',
+ }),
+ ).not.toBeNull();
+ expect(
+ within(composer as HTMLElement).getByLabelText('陶泥儿对话内容'),
+ ).not.toBeNull();
+
+ // 设置浮层:独立浮层,包含运行配置与操作权限。
+ fireEvent.click(
+ within(supervisorSurface).getByRole('button', { name: '设置' }),
+ );
+ const settingsDialog = await screen.findByRole('dialog', {
+ name: '对话设置',
+ });
+ expect(settingsDialog).not.toBeNull();
+ expect(within(settingsDialog).getByText('运行配置')).not.toBeNull();
+ // 打开审批模式:它是浮层里的二级浮层,选项在,选完还能退回设置浮层。
+ fireEvent.click(
+ within(settingsDialog).getByRole('button', {
+ name: '审批配置,当前严格审批',
+ }),
+ );
+ const approvalDialog = await screen.findByRole('dialog', {
+ name: '陶泥儿的操作权限',
+ });
+ expect(
+ within(approvalDialog).getByRole('radio', { name: /严格审批/ }),
+ ).not.toBeNull();
+ fireEvent.click(
+ within(approvalDialog).getByRole('button', { name: '完成' }),
+ );
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('dialog', { name: '陶泥儿的操作权限' }),
+ ).toBeNull();
+ });
+ fireEvent.keyDown(window, { key: 'Escape' });
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog', { name: '对话设置' })).toBeNull();
+ });
+ view.unmount();
+ });
+
it('does not poll or render legacy professional Agent runtime state in direct product chat', async () => {
const projectPath = '/tmp/launcher-runtime-status-game';
let professionalRuntimeReadCount = 0;
diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
index bae420fcc..ec2e2f136 100644
--- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
+++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
@@ -96,6 +96,27 @@ function paddingBox(declarations: Map
) {
};
}
+/** 展开 `margin` 简写,只关心四边数值。 */
+function marginBox(declarations: Map) {
+ const shorthand = declarations.get('margin') ?? '0';
+ const parts = shorthand.split(' ').filter(Boolean);
+ const value = (index: number) => {
+ const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
+ return lengthPx(part!, `margin 简写 ${shorthand}`);
+ };
+ if (parts.length === 1) {
+ const only = value(0);
+ return { top: only, right: only, bottom: only, left: only };
+ }
+ if (parts.length === 2) {
+ return { top: value(0), right: value(1), bottom: value(0), left: value(1) };
+ }
+ if (parts.length === 3) {
+ return { top: value(0), right: value(1), bottom: value(2), left: value(1) };
+ }
+ return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
+}
+
/* ============================================================
选择器身份
============================================================ */
@@ -103,6 +124,8 @@ function paddingBox(declarations: Map) {
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 MESSAGE_LIST_PLAIN =
+ '.game-workbench-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`;
@@ -111,10 +134,18 @@ 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 COMPOSER_CONTEXT = `${COMPOSER_PLAIN} .project-supervisor-composer-context`;
const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`;
+const TOPBAR = `${CHAT} .project-supervisor-topbar`;
// 策划链在会话列里额外挂了一条规划面/窄条时,列表会命中这条 `:has(...)` 规则——
-// 它同样声明了 `padding-bottom`,正是上一轮把留白改回 12px 的那种隐患。
+// 它同样声明了 `padding-bottom`,是这套几何里唯一"外来的"留白来源。
const LIST_WITH_PLAN_SURFACE = `.game-workbench-chat .project-supervisor-conversation:has(.plan-gdd-surface, .planning-lane-runtime-strip) .project-supervisor-message-list`;
+/** 列表实际会命中的全部选择器:direct-codex 直连、`:has(...)` 变体、以及基础规则。 */
+const LIST_SELECTORS = [
+ MESSAGE_LIST,
+ LIST_WITH_PLAN_SURFACE,
+ MESSAGE_LIST_PLAIN,
+];
const DESKTOP = 1440;
const MOBILE = 390;
@@ -130,7 +161,12 @@ function mobileDeclarations(...elementSelectors: string[]) {
return resolveDeclarations(rules, elementSelectors, MOBILE);
}
-/** 留白算术里的每一个常量都必须来自文件里真实生效的声明。 */
+/**
+ * 输入盒在文档流里的最高高度。
+ *
+ * 新结构里输入盒**不再**需要消息列表给它让位,这个值只用来给「列表底边不留白」做
+ * 下界校验:留白只要不小于它就说明旧模型的数字被写回来了。
+ */
function composerMaxHeight() {
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
const input = desktopDeclarations(
@@ -150,6 +186,7 @@ function composerMaxHeight() {
`${INPUT_ACTIONS} .resource-reference-input-polish`,
'.resource-reference-input-polish',
);
+ const context = desktopDeclarations(COMPOSER_CONTEXT);
const controls = desktopDeclarations(COMPOSER_CONTROLS);
const submit = desktopDeclarations(COMPOSER_SUBMIT);
@@ -164,12 +201,15 @@ function composerMaxHeight() {
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);
+ // 信息标签行只在 direct-codex 渲染,也只占一行,不设固定高度。
+ expect(context.has('height')).toBe(false);
+ expect(context.has('min-height')).toBe(false);
return {
actionsRowHeight,
@@ -184,166 +224,139 @@ function composerMaxHeight() {
};
}
-describe('陶泥儿对话区:外框完整包住输入区', () => {
- it('外框四边贴会话区,输入区四边都在外框之内且不重合(宽屏)', () => {
- const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
+describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文档流输入盒)', () => {
+ it('输入盒在消息列表下方、处于文档流,不再浮在列表之上(宽屏)', () => {
+ const list = desktopDeclarations(...LIST_SELECTORS);
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');
+ // 列表是唯一滚动区:自身不再绝对定位,靠 flex 吸收会话列的剩余高度。
+ expect(declaration(list, 'position')).toBe('relative');
+ expect(declaration(list, 'overflow-y')).toBe('auto');
+ expect(declaration(list, 'flex')).toContain('1 1 auto');
- 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(declaration(composer, 'position')).toBe('relative');
+ const insets = marginBox(composer);
+ expect(insets.top).toBe(0);
+ expect(insets.left).toBe(0);
+ expect(insets.right).toBe(0);
+ expect(insets.bottom).toBe(0);
+ // 也不再靠 bottom/left/right 偏移量定位(那是旧浮层几何的残余)。
+ expect(pixelValue(composer, 'bottom')).toBe(0);
+ expect(pixelValue(composer, 'left')).toBe(0);
+ expect(pixelValue(composer, 'right')).toBe(0);
- // 外框四边就是会话区那只盒子——它才是"对话框"。
- 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)',
);
+ expect(declaration(composer, 'border-radius')).toBe('14px');
+
+ // 对话内容左右留白 16px,消息之间 14px。
+ expect(paddingBox(list).left).toBe(16);
+ expect(paddingBox(list).right).toBe(16);
+ expect(declaration(list, 'gap')).toBe('14px');
});
- it('消息列表底部留白 = 输入区内缩 + 输入区最高高度 + 间距,且与 scroll-padding 同值', () => {
- const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
- const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
+ it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
+ const list = desktopDeclarations(...LIST_SELECTORS);
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);
+ // 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
+ // + 输入盒上下内边距 18 + 操作条(2 + 28 方钮)= 228。
+ expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28);
+ expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
expect(total).toBe(228);
- // 留白 ≥ 输入区最高高度 + 内缩:滚到底时最后一条消息不会被输入区盖住。
- expect(paddingBottom).toBeGreaterThanOrEqual(inset + total);
- // 且留白就该等于这条算术式,不允许多出一个"来历不明"的常量。
- expect(paddingBottom).toBe(inset + total + gap);
+ const paddingBottom = paddingBox(list).bottom;
+ // 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式,
+ // 输入盒回到文档流之后,那段留白会变成凭空多出来的空白,把最后一条消息顶出可视区。
+ expect(paddingBottom, '输入盒在文档流里,列表底边不允许再给浮层留白').toBe(
+ 0,
+ );
expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom);
+ // 留白必须严格小于输入盒高度,否则就是把旧模型的数字又写回来了。
+ expect(paddingBottom).toBeLessThan(total);
});
- it('窄屏把外框交给会话列,输入区回到文档流,四周仍有一圈边距(390px)', () => {
+ it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
const conversation = mobileDeclarations(CONVERSATION);
- const list = mobileDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE);
+ const list = mobileDeclarations(...LIST_SELECTORS);
const composer = mobileDeclarations(
COMPOSER,
COMPOSER_PLAIN,
CONVERSATION_COMPOSER,
);
+ const topbar = mobileDeclarations(TOPBAR);
- // 外框画在列表与输入区共同的父节点上,用的还是那套 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');
+ expect(framePadding.top).toBe(0);
+ expect(framePadding.right).toBe(0);
+ expect(framePadding.bottom).toBe(0);
+ expect(framePadding.left).toBe(0);
+ expect(declaration(conversation, 'border')).toBe('0');
+ expect(declaration(conversation, 'background')).toBe('transparent');
- // 列表在窄屏不再自画一只框:框里不套框,也就不会出现两条描边压在一起。
- 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);
+ const composerMargin = marginBox(composer);
+ expect(composerMargin.left, '窄屏输入盒左右必须有边距').toBeGreaterThan(0);
+ expect(composerMargin.right).toBe(composerMargin.left);
+ expect(composerMargin.bottom, '窄屏输入盒底部必须有边距').toBeGreaterThan(
+ 0,
+ );
+
+ // 列表在窄屏也不画框、不留浮层空白,它仍是唯一滚动区。
+ expect(declaration(list, 'background')).toBe('transparent');
+ expect(pixelValue(list, 'padding-bottom')).toBe(0);
+ expect(declaration(list, 'overflow-y')).toBe('auto');
+
+ // 顶栏在窄屏也在场(状态点 + 设置钮),高度不塌。
+ expect(declaration(topbar, 'display')).toBe('flex');
+ expect(pixelValue(topbar, 'min-height')).toBe(44);
});
- it('输入区仍落在对话框外框节点的子树里,交互控件原位', () => {
+ it('输入盒仍落在 form.project-supervisor-composer 子树里,交互控件原位', () => {
const source = readFileSync(VIEW_PATH, 'utf8');
+ const formOpen = source.indexOf(''.length);
+
+ // 编辑器、`+` / `@` 引用、模型选择、提交按钮全在这棵子树里,位置只由样式挪。
+ expect(formSource).toContain('ResourceReferenceInput');
+ expect(formSource).toContain('project-supervisor-composer-context');
+ expect(formSource).toContain('project-supervisor-composer-controls');
+ expect(formSource).toContain('project-supervisor-attachment-trigger');
+ expect(formSource).toContain('project-supervisor-reference-trigger');
+ expect(formSource).toContain('ConversationModelSelect');
+ expect(formSource).toContain('{submitButton}');
+ expect(formSource).toContain('onSubmit={');
+ // 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。
+ expect(source).toContain('className="project-supervisor-submit-button"');
+
+ // 面板三段式的顺序:顶栏 → 消息列表 → 输入盒。
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(
+ expect(conversationOpen).toBeGreaterThanOrEqual(0);
+ const topbarIndex = source.indexOf(
+ 'project-supervisor-topbar',
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,
+ const listIndex = source.indexOf(
+ 'className="message-list project-supervisor-message-list"',
+ conversationOpen,
);
- // 编辑器、@ 引用、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"');
+ expect(topbarIndex).toBeGreaterThan(conversationOpen);
+ expect(listIndex, '消息列表必须排在顶栏之后').toBeGreaterThan(topbarIndex);
+ expect(formOpen, '输入盒必须排在消息列表之后').toBeGreaterThan(listIndex);
});
});