DeepSeek-Harness 提问 Composer:选项行为何是滚动内容,而非空间不足时的“吸收方”
本篇基于 deepseek-harness 仓库中的一份已归档缺陷修复记录(2026-07-27-question-composer-rows-do-not-shrink.md),讲解 Web 客户端提问卡片(question composer)在矮视口下选项行互相重叠、盖住标题这一缺陷的根因、修复决策(flex-shrink: 0)、被否决的四个替代方案,以及 e2e 测试如何防止该布局不变式“空洞地成立”。读完后你应能掌握一套排查 Flexbox 高度压缩类布局缺陷的方法:定位“高度不足由谁吸收”,用滚动容器承接溢出,并用带守卫条件的 e2e 断言把修复钉死。
缺陷背景:一个被视口限高、靠内部滚动存活的提问卡片
当 Agent 调用 ask_user_question 工具向用户提问时,Web 客户端的输入区会被提问 composer 接管,一次渲染一道题(含单选/多选选项、推荐标记、自定义输入、翻页与提交按钮)。相关组件与样式位于 QuestionComposer.tsx 和 QuestionComposer.module.css。
卡片的结构约定是:
- 卡片
.card按视口限高:max-height: min(60vh, 520px)(QuestionComposer.module.css),并overflow: hidden; - 头部(标题、收起/关闭按钮)与底部(翻页、跳过、提交按钮)固定在卡片两端,始终可达;
- 中间的选项区负责滚动,这样在成批提问时,头部与底部操作不会被长选项列表挤出屏幕。
这个设计成立的前提是:composer 所在的座位(conversation 列)是一个固定高度且 overflow: hidden 的容器。限高不是为了好看,而是保证提交按钮永远留在屏幕内。
缺陷的触发条件是“座位变矮”:窗口较小,或视口偏矮且详情面板处于展开状态。此时观察到的现象是——选项行叠在彼此之上,也叠在问题标题之上。
根因:高度上限不是病灶,高度不足“由谁吸收”才是
原记录的判断很关键:缺陷不在 max-height 这个上限,而在不足空间的分配方式。
机制拆解如下:
- 选项列表容器是
flex-direction: column的盒子(当前 CSS 中为.options,QuestionComposer.module.css),其子元素默认取flex-shrink: 1; - 当卡片可用高度不足时,Flex 布局首先压缩的是选项行本身,而不是让滚动容器产生溢出;
- 一行会被压到它的
min-height(记录当时为 42px;当前 CSS 中.option为min-height: 40px,见 QuestionComposer.module.css),而.optionCopy仍保持文案折行后所需的更高固有高度——带描述的选项通常要占两行; - 在当时的
align-items: center对齐下,文案以一个比自身更矮的盒子为基准居中,向上下两个方向画出该行边框盒之外:向上盖住标题,向下盖住下一行; - 更隐蔽的是,由于行自身吸收了全部缺口,滚动容器报告的
scrollHeight === clientHeight,滚动条根本不会出现——这是一个“静默画错”的布局状态。
实测数据(发布版客户端):900x440 视口下,有 6.5px 文案落在行盒之外;视口高度降到 380px 时增至 10px;而 .options 始终报告 scrollHeight === clientHeight,从不提供滚动条。
还有一条重要的复现边界:只有文案会折行的选项行才能复现。文案单行即可容纳的行,其内容与最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(选项为 Blue/Green、无描述)在任何尺寸下都渲染正常的原因,也解释了为什么这个缺陷能在既有测试全绿的情况下存在。
决策:.option 与 .custom 声明 flex-shrink: 0
原记录的决策一句话:这些行是限高卡片里的滚动内容,而不是空间不足时的吸收方。当前仓库的 CSS 中可以看到这条规则及其注释(QuestionComposer.module.css):
.option {
/* ... */
/* 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;
/* ... */
}
自定义答案行 .customRow 带着同样的理由声明 flex-shrink: 0(QuestionComposer.module.css),注释明确写道:“the custom row is scroll content, and shrinking it pushes the inline input past the footer”。
推理链条是:
- 卡片的溢出本来就该由滚动容器承担——当前实现中该滚动座位是
.body(即 e2e 中以data-question-scroll标记的元素),它持有flex: 1 1 auto、min-height: 0、overflow-y: auto(QuestionComposer.module.css); - 把行固定住之后,高度不足会传导到滚动容器而不是被行吸收,卡片按设计进入滚动状态;
- 被否决的另一条路——允许行压缩、但把文案约束在行内——要求在用户最需要阅读选项描述的那些尺寸上对描述做裁剪或省略号处理,与产品目标冲突;
.header与.footer出于同样的原因早已在卡片层级携带flex-shrink: 0(QuestionComposer.module.css 与 L401-L409);选项列表的子元素正是这条规则缺失的另一半。
一个值得注意的细节:当前仓库中 .option 的对齐方式已是 align-items: flex-start 而非 center(QuestionComposer.module.css),其注释解释了动机——当描述折行时,序号/复选框指示器必须留在第一行上,居中会把指示器推到更高的文案块中间;20px 指示器相对 24px 首行盒用 margin-top: 2px 重新居中。从源码结构看,这与原记录“被压缩的行里居中文案会上下双向溢出”的根因分析一致:对齐方式影响的是溢出方向与指示器位置,flex-shrink: 0 影响的才是“行是否允许塌缩”,两者解决的是同一根因的不同侧面。
被否决的四个替代方案(及否决理由)
原记录完整保留了决策过程,四个候选方案均被否决:
- 在被压缩的行内裁剪文案或加省略号(对
.option设overflow: hidden)。 一条声明即可消除重叠,无需重新考虑布局。否决理由:它把一个可见缺陷换成了一个无声缺陷——行仍保持最小高度,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。选项描述是决策相关内容,不是装饰。 - 把
align-items: center改为align-items: flex-start。 文案只会向下生长,不再向上盖住标题。否决理由(针对当时语境):被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声地改变每个选项行的垂直对齐。 - 移除卡片的
max-height上限,使其永不被压缩。 没有高度不足就没有分配问题。否决理由:正是这个上限保证成批提问时头部和底部操作留在屏幕内;composer 座位是固定高度、overflow: hidden的会话列,移除上限会重新引入该上限本就为之存在的失败——不设限的卡片会连自己的提交按钮一起丢掉。 - 把折行文案限制为单行(对
.description设white-space: nowrap加省略号)。 行永不折行,被压缩时也永不溢出。否决理由:与方案 1 相同(丢失决策相关文案),且为了修一个窄视口缺陷,还牺牲了空间充裕的宽视口下的渲染效果。
修复后的行为:滚动代替重叠
原记录给出的后果(Consequences):
- 被压缩的 composer 现在滚动选项列表而不是让行互相重叠:在 900x380 下,列表报告
scrollHeight为 200、clientHeight为 114,并给出滚动条;修复前两者相等、无滚动条; - 选项行在任何视口尺寸下保留完整的折行文案。不裁剪、不加省略号;宽视口渲染保持不变——
flex-shrink: 0只在 flex 盒子空间不足时才生效; - 由于高度不足不再被行部分吸收,卡片更早进入滚动状态。这正是限高设计想要的行为:较矮座位下,此前静默画错的列表现在显示滚动条;
- 该 e2e 场景录制的问题文本比它主要测试的往返所需的更长——这个代价是有意付出的:没有折行文案,布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。
e2e 验证:让断言无法“空洞地成立”
Web e2e 场景 question-composer.e2e.ts 在实际运行的 composer 上断言该不变式,而不是对静态 DOM 做几何假设:
受挤压断言(question-composer.e2e.ts):在三个受挤压的座位高度(900x520 / 440 / 380)下,对每个选项行测量其子元素相对行边框盒的溢出量,要求 spill < 0.6px(亚像素容差)。断言刻意使用 role/ARIA 选择器([role="radio"]、[role="checkbox"])而非 CSS Module 类名,因为构建产物中的类名是哈希的。
两道防“空洞成立”的守卫(question-composer.e2e.ts):
wrappedRows > 0:至少一行处于折行状态(行高 > 42px)——折行是唯一会溢出的形态;scrolls === true:滚动容器([data-question-scroll])的scrollHeight > clientHeight——证明座位确实被限高卡住了。
没有这两个守卫,上面的溢出断言在任何尺寸下都会平凡地通过。这正是 fixture 里 PROMPT 特意给 Blue/Green 两个选项配上长描述的原因——“没有折行文案,这条断言不可能失败”。
双向确认:在构建产物客户端上,撤销 flex-shrink: 0 后场景失败(scrolls: false,6.5px 溢出),恢复后通过。另有一次覆盖 340 种视口尺寸(420–1600 x 320–960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外降到 0。
回放模式限定:该断言仅在执行 MODE !== 'record' 时运行——录制模式必须走到 fixture 写入步骤,不能在布局检查处中止。该场景的 golden 与 session fixture 位于 snapshots/web/question-composer/。
同一场景还顺带锁定了 composer 的另一条高度规则:自由文本输入框以隐藏镜像元素(.fieldMirror,max-height: 144px,对应 6 行文本)控制增长,超过上限后由 textarea 自身滚动(QuestionComposer.module.css);e2e 中 capMetrics 以文本行数而非盒高断言该上限,因为两种变体(inline/block)padding 不同,用 border-box 像素度量会在不同变体中悄悄对应不同行数。
复现与构建的实操注意事项
原记录的 Verification 章节还给出了几条极易踩坑的边界条件,均值得保留为团队知识:
复现需要矮视口,而不是矮容器。 上限是 min(60vh, 520px)——把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足:各行仍保持完整高度,什么都不会溢出。因此在 e2e 场景之外演示或测量该缺陷,必须改变视口(这正是 e2e 用 page.setViewportSize 的原因)。
pnpm run build:web 单独执行不足以让浏览器通道看到 CSS 改动。 composer 以客户端模块包(@deepseek-ai/dsh-client-ui-user-questions)形式随包构建发布;根目录 package.json 中 build:web 仅 filter 构建 web 前端包本身。必须执行包构建,浏览器测试通道才能看到 QuestionComposer.module.css 的变更。
陈旧 lib/ 会让浏览器通道对着比工作树更旧的客户端做断言。 中途失败的 pnpm run build 留下的正是这种状态:失败之前构建的包是新的,其余不是;在此状态下刷新 golden,记录下来的是旧客户端的界面。抓取前先确认构建以 0 退出。另注意 packages/ 下的未跟踪目录同样会被编译——来自其他分支的遗留物可能以 diff 无法解释的原因让构建失败。
参考与延伸阅读
- 原决策记录(英文/中文):2026-07-27-question-composer-rows-do-not-shrink.md、中文版本
- 组件实现:QuestionComposer.tsx(含
QuestionFlow、AnswerField镜像增长输入框、choose/submitDrafts提交流程) - 样式与修复规则:QuestionComposer.module.css(卡片限高 L20、滚动座位
.bodyL146-L153、.option的flex-shrink: 0L173-L177、.customRowL304-L306) - e2e 场景与守卫断言:question-composer.e2e.ts
- 包功能文档(composer 接管语义、草稿 Store、plan-review 变体):dsh-client-ui-user-questions README
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