首页
/ deepseek-harness 提问 Composer 选项行布局不变式:用 flex-shrink: 0 让滚动容器而非行本身吸收溢出

deepseek-harness 提问 Composer 选项行布局不变式:用 flex-shrink: 0 让滚动容器而非行本身吸收溢出

2026-09-04 19:34:43作者:尤峻淳Whitney

当 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: automin-height: 0,是整张卡片中唯一的溢出承担者;在 QuestionComposer.tsx 中它被标记为 data-question-scroll,供 e2e 测试定位。.header.footer 也各自声明了 flex-shrink: 0QuestionComposer.module.cssQuestionComposer.module.css),保证挤压时先滚动内容区,而不是压扁标题栏和按钮栏。

问题的另一半——选项行——在修复之前恰恰缺了这条规则。

缺陷根因:flex 子项默认 flex-shrink: 1,压缩落在行上而不在滚动容器上

.options 是一个 flex-direction: column 的盒子(QuestionComposer.module.css),其子元素默认取 flex-shrink: 1。当 composer 的可用区域变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),空间不足首先压缩的是各个选项行,而不是让滚动容器产生溢出。

具体失效链是:

  1. 一行被压到它的最小高度之下(决策记录中记为 42px,当前源码中 .optionmin-height 为 40px,见 QuestionComposer.module.css);
  2. .optionCopy 仍保持文案折行后所需的更大固有高度——带描述的选项会占两行;
  3. 文案以一个比自身更矮的盒子为基准居中,向上下两个方向画到该行边框盒之外:向上盖住标题,向下盖住下一行
  4. 同时滚动容器报告的 scrollHeight 等于 clientHeight(因为内容总高被"压扁"到不再溢出),永远给不出滚动条

决策记录给出了发布客户端上的实测数据:在 900x440 处,文案有 6.5px 落在行盒之外;视口高度降到 380px 时增至 10px。

一个容易误判的事实:只有文案会折行的选项行才能复现该问题。单行即可容纳的行,其内容与最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(选项为 BlueGreen、无描述)在任何尺寸下都渲染正常、缺陷得以潜伏的原因。

修复决策:.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: 0QuestionComposer.module.css),其注释说明:压缩它会推挤行内的输入框,把它推出页脚。

原理上,把子元素固定住之后,高度不足会传导到那个持有 overflow-y: automin-height: 0 的滚动容器,而不再被行本身吸收——这正是高度上限设计时想要的行为。.header.footer 早在卡片层级就带有 flex-shrink: 0,选项列表的子元素是这条规则缺失的另一半,本次修复把它补齐。

另从当前源码看,行的对齐已从缺陷期的 align-items: center 演进为 flex-start:注释解释在带折行描述时,指示器(序号/复选框)必须留在第一行上,居中会随更高的一整块文案下漂,单行行靠 8px padding 恰好 40px、不显头重,.number/.checkbox 再用 2px 上边距对第一行盒重新居中(QuestionComposer.module.cssQuestionComposer.module.css)。

被否决的四种替代方案

决策记录逐一列出了替代方案与否决理由,值得作为布局决策的对照清单:

  • 在被压缩的行内裁剪文案或加省略号(对 .optionoverflow: hidden)。 一条声明就能消除重叠,不必重新考虑布局。否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持最小高度,选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述是影响决策的内容,不是装饰。
  • align-items: center 改为 align-items: flex-start 文案只向下生长,不再向上盖住标题。否决:什么也没修好——被压缩的行依然溢出到下一行上,而且这一改动会在所有尺寸(包括常见尺寸)下无声改变每个选项行的垂直对齐。
  • 移除卡片的 max-height 上限,使其永远不会被压缩。 没有高度不足就没有分配问题。否决:正是这个上限保证成批提问时头部和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败——composer 处于固定高度、overflow: hidden 的会话列中,不设上限的卡片会连自己的提交按钮一起丢掉。
  • 把折行文案限制为单行(对 .descriptionwhite-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.jsonlui.expected.mdcomposed.expected.mdanswered.expected.md 等。

工程陷阱:构建通道与"矮视口 vs 矮容器"

决策记录"验证"一节还沉淀了三条容易踩的坑,与仓库构建结构一一对应:

  1. 断言只在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。
  2. 客户端模块包必须先构建。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:builtpackage.json):先整体构建,再对构建产物做浏览器断言。
  3. lib/ 陈旧会让测试对着更旧的客户端断言。中途失败的 pnpm run build 留下的正是这种状态:失败之前构建的那些包是新的,其余不是;在这种状态下刷新预期输出,记录下来的是旧客户端的界面。抓取前应先确认构建以 0 退出;packages/ 下的未跟踪目录同样会被编译,来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。

此外,要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限是 min(60vh, 520px),把会话列压到比卡片自身高度更矮,只会裁剪卡片而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。所以凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口(这正是 e2e 里用 page.setViewportSize 而非改布局的原因)。

可迁移的三条布局原则

把这条决策记录抽象出来,它给出了三条可以复用到任何"带高度上限的卡片"的经验:

  1. 先回答"高度不足由谁吸收":在设有高度上限的 flex 卡片中,滚动内容(选项行)与滚动容器(.body)的角色必须显式声明——内容侧 flex-shrink: 0,容器侧 min-height: 0 + overflow-y: auto。默认 flex-shrink: 1 会让压缩静默落在内容上,产生"文案画出行盒、却没有滚动条"的无声失效。
  2. 警惕可见缺陷与无声缺陷的互换overflow: hidden、省略号、nowrap 这类方案能消除重叠,却把布局错误从"看得见"变成"看不见",在用户最需要阅读内容的紧张尺寸上悄悄丢信息。
  3. 布局不变式的测试必须可证伪:断言"子元素不出行盒"若配不上"至少一行在折行"和"列表确实在滚动"这两道守卫,就会在 fixture 不折行时空洞通过;因此录制的 fixture 描述故意比往返所需更长——测试数据的冗余不是浪费,而是不变式能被触发的条件。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384