deepseek-harness 提问 Composer 选项行布局不变式:用 flex-shrink: 0 让滚动容器而非行本身吸收溢出
当 Agent 在回合中调用 ask_user_question 工具向用户提问时,deepseek-harness 的 Web 客户端会在输入区上方呈现一张提问 composer 卡片,选项行是这张卡片的滚动内容。本文基于仓库中已归档的 bug-fix 决策记录 2026-07-27-question-composer-rows-do-not-shrink.zh.md,完整还原这个"选项行互相重叠、盖住问题标题"缺陷的根因、修复决策、被否决的替代方案与 e2e 验证手段;读完后你可以掌握"带高度上限的 flex 卡片中,高度不足应由谁来吸收"这一布局不变式的设计思路,以及如何在浏览器测试中让它可被证伪。
卡片布局的既有设计:高度上限与滚动容器
提问 composer 的卡片按视口设高度上限 max-height: min(60vh, 520px),并让选项列表自行滚动,这样在成批提问时头部和底部的操作按钮始终可达。当前源码中这一设计位于 QuestionComposer.module.css:
.card {
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
...
}
注意注释里点明的前提:composer 所处的会话列是一个固定高度、overflow: hidden 的容器,所以卡片必须自我设限。滚动职责则落在 body 区块上(QuestionComposer.module.css):
.body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.body 持有 overflow-y: auto 与 min-height: 0,是整张卡片中唯一的溢出承担者;在 QuestionComposer.tsx 中它被标记为 data-question-scroll,供 e2e 测试定位。.header 与 .footer 也各自声明了 flex-shrink: 0(QuestionComposer.module.css、QuestionComposer.module.css),保证挤压时先滚动内容区,而不是压扁标题栏和按钮栏。
问题的另一半——选项行——在修复之前恰恰缺了这条规则。
缺陷根因:flex 子项默认 flex-shrink: 1,压缩落在行上而不在滚动容器上
.options 是一个 flex-direction: column 的盒子(QuestionComposer.module.css),其子元素默认取 flex-shrink: 1。当 composer 的可用区域变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),空间不足首先压缩的是各个选项行,而不是让滚动容器产生溢出。
具体失效链是:
- 一行被压到它的最小高度之下(决策记录中记为 42px,当前源码中
.option的min-height为 40px,见 QuestionComposer.module.css); - 而
.optionCopy仍保持文案折行后所需的更大固有高度——带描述的选项会占两行; - 文案以一个比自身更矮的盒子为基准居中,向上下两个方向画到该行边框盒之外:向上盖住标题,向下盖住下一行;
- 同时滚动容器报告的
scrollHeight等于clientHeight(因为内容总高被"压扁"到不再溢出),永远给不出滚动条。
决策记录给出了发布客户端上的实测数据:在 900x440 处,文案有 6.5px 落在行盒之外;视口高度降到 380px 时增至 10px。
一个容易误判的事实:只有文案会折行的选项行才能复现该问题。单行即可容纳的行,其内容与最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(选项为 Blue/Green、无描述)在任何尺寸下都渲染正常、缺陷得以潜伏的原因。
修复决策:.option 与 .custom 声明 flex-shrink: 0
修复只有一行声明的语义变化:选项行与自定义回答行都是滚动内容,不是空间不足时的吸收方。当前源码中:
.option {
display: flex;
...
min-height: 40px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
...
}
见 QuestionComposer.module.css。自定义回答行 .customRow 以同样的理由声明 flex-shrink: 0(QuestionComposer.module.css),其注释说明:压缩它会推挤行内的输入框,把它推出页脚。
原理上,把子元素固定住之后,高度不足会传导到那个持有 overflow-y: auto 与 min-height: 0 的滚动容器,而不再被行本身吸收——这正是高度上限设计时想要的行为。.header 与 .footer 早在卡片层级就带有 flex-shrink: 0,选项列表的子元素是这条规则缺失的另一半,本次修复把它补齐。
另从当前源码看,行的对齐已从缺陷期的 align-items: center 演进为 flex-start:注释解释在带折行描述时,指示器(序号/复选框)必须留在第一行上,居中会随更高的一整块文案下漂,单行行靠 8px padding 恰好 40px、不显头重,.number/.checkbox 再用 2px 上边距对第一行盒重新居中(QuestionComposer.module.css、QuestionComposer.module.css)。
被否决的四种替代方案
决策记录逐一列出了替代方案与否决理由,值得作为布局决策的对照清单:
- 在被压缩的行内裁剪文案或加省略号(对
.option设overflow: hidden)。 一条声明就能消除重叠,不必重新考虑布局。否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持最小高度,选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述是影响决策的内容,不是装饰。 - 把
align-items: center改为align-items: flex-start。 文案只向下生长,不再向上盖住标题。否决:什么也没修好——被压缩的行依然溢出到下一行上,而且这一改动会在所有尺寸(包括常见尺寸)下无声改变每个选项行的垂直对齐。 - 移除卡片的
max-height上限,使其永远不会被压缩。 没有高度不足就没有分配问题。否决:正是这个上限保证成批提问时头部和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败——composer 处于固定高度、overflow: hidden的会话列中,不设上限的卡片会连自己的提交按钮一起丢掉。 - 把折行文案限制为单行(对
.description设white-space: nowrap加省略号)。 行永不折行,被压缩时也就永不溢出。否决:理由与裁剪相同,且它为了修一个窄视口缺陷,牺牲了空间充裕的宽视口下的渲染效果。
后果:缺陷从"无声画错"变为"可滚动"
修复后的可观察行为(均见决策记录"后果"一节):
- 被压缩的 composer 会滚动其选项列表,而不是让选项行互相重叠。在 900x380 处,该列表报告
scrollHeight为 200、clientHeight为 114,并给出滚动条;此前两者相等,不给滚动条。 - 选项行在任何视口尺寸下都保留完整的折行文案——不裁剪、不加省略号;宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。
- 由于高度不足不再被行部分吸收,卡片更早进入滚动状态。这正是高度上限想要的行为:在此前只会无声画错列表的情形下,较矮的可用区域现在会显示滚动条。
- 为录制 fixture 而加长的问题描述,比该场景主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。
e2e 验证:三个挤压高度、两道防空洞守卫
Web e2e 场景位于 apps/web/tests/question-composer.e2e.ts。录制的 fixture 问题刻意带上长选项描述(question-composer.e2e.ts):
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only text layout that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question ...
label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", ...'
回放模式下,场景在 900x520/440/380 三个受挤压的可用区域高度上,对实际运行的 composer 断言不变式:每个选项行的子元素都留在该行的边框盒之内(question-composer.e2e.ts):
for (const height of [520, 440, 380]) {
await page.setViewportSize({ width: 900, height })
const squeeze = await composer.evaluate((card) => {
const rows = [...card.querySelectorAll<HTMLElement>(
'[role="radio"], [role="checkbox"], [aria-expanded]',
)]
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
const box = row.getBoundingClientRect()
const inner = child.getBoundingClientRect()
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
})))
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
return {
rows: rows.length,
spill: Math.max(...spill),
// Wrapped option text is what overflows a collapsed row, and a
// scrolling list proves the seat is genuinely capped. Without both,
// the spill assertion would hold vacuously.
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
}
})
expect(squeeze.rows).toBeGreaterThan(0)
expect(squeeze.wrappedRows).toBeGreaterThan(0)
expect(squeeze.scrolls).toBe(true)
// Sub-pixel tolerance: every row's copy stays inside its border box.
expect(squeeze.spill).toBeLessThan(0.6)
}
注意实现细节:选择器用的是角色/ARIA([role="radio"]、[data-question-scroll]),而不是 CSS Module 类名——构建后的客户端会把类名哈希掉。
该断言配有两道守卫防止它空洞地成立:必须至少有一行处于折行状态(wrappedRows > 0,这是唯一会溢出的形态),且滚动容器必须确实处在滚动状态(scrolls === true,证明可用区域确实受到了高度上限约束)。
验证闭环还包括在构建产物客户端上做的双向确认:撤销 flex-shrink: 0 后该场景失败(scrolls: false,6.5px 溢出),恢复后通过。另有一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。
对应的黄金文件与 fixture 存放在 snapshots/web/question-composer/,包含 session.jsonl、ui.expected.md、composed.expected.md、answered.expected.md 等。
工程陷阱:构建通道与"矮视口 vs 矮容器"
决策记录"验证"一节还沉淀了三条容易踩的坑,与仓库构建结构一一对应:
- 断言只在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。
- 客户端模块包必须先构建。composer 以客户端模块包
@deepseek-ai/dsh-client-ui-user-questions的形式发布(见 packages/client/ui-user-questions/package.json,构建脚本为bundle: tsdown,产物落在lib/),因此单跑pnpm run build:web不会带上对QuestionComposer.module.css的改动——必须执行包构建,浏览器测试通道才能看到它。这也解释了根脚本里test:web的定义是npm run build && npm run test:web:built(package.json):先整体构建,再对构建产物做浏览器断言。 lib/陈旧会让测试对着更旧的客户端断言。中途失败的pnpm run build留下的正是这种状态:失败之前构建的那些包是新的,其余不是;在这种状态下刷新预期输出,记录下来的是旧客户端的界面。抓取前应先确认构建以 0 退出;packages/下的未跟踪目录同样会被编译,来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。
此外,要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限是 min(60vh, 520px),把会话列压到比卡片自身高度更矮,只会裁剪卡片而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。所以凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口(这正是 e2e 里用 page.setViewportSize 而非改布局的原因)。
可迁移的三条布局原则
把这条决策记录抽象出来,它给出了三条可以复用到任何"带高度上限的卡片"的经验:
- 先回答"高度不足由谁吸收":在设有高度上限的 flex 卡片中,滚动内容(选项行)与滚动容器(
.body)的角色必须显式声明——内容侧flex-shrink: 0,容器侧min-height: 0+overflow-y: auto。默认flex-shrink: 1会让压缩静默落在内容上,产生"文案画出行盒、却没有滚动条"的无声失效。 - 警惕可见缺陷与无声缺陷的互换:
overflow: hidden、省略号、nowrap这类方案能消除重叠,却把布局错误从"看得见"变成"看不见",在用户最需要阅读内容的紧张尺寸上悄悄丢信息。 - 布局不变式的测试必须可证伪:断言"子元素不出行盒"若配不上"至少一行在折行"和"列表确实在滚动"这两道守卫,就会在 fixture 不折行时空洞通过;因此录制的 fixture 描述故意比往返所需更长——测试数据的冗余不是浪费,而是不变式能被触发的条件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00