
deepseek-harness 提问 Composer 选项行布局不变式用 flex-shrink: 0 让滚动容器而非行本身吸收溢出【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness当 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: 0QuestionComposer.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选项为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: 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., ...回放模式下场景在 900x520440380 三个受挤压的可用区域高度上对实际运行的 composer断言不变式每个选项行的子元素都留在该行的边框盒之内question-composer.e2e.tsfor (const height of [520, 440, 380]) { await page.setViewportSize({ width: 900, height }) const squeeze await composer.evaluate((card) { const rows [...card.querySelectorAllHTMLElement( [roleradio], [rolecheckbox], [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.querySelectorHTMLElement([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 rows copy stays inside its border box. expect(squeeze.spill).toBeLessThan(0.6) }注意实现细节选择器用的是角色/ARIA[roleradio]、[data-question-scroll]而不是 CSS Module 类名——构建后的客户端会把类名哈希掉。该断言配有两道守卫防止它空洞地成立必须至少有一行处于折行状态wrappedRows 0这是唯一会溢出的形态且滚动容器必须确实处在滚动状态scrolls true证明可用区域确实受到了高度上限约束。验证闭环还包括在构建产物客户端上做的双向确认撤销flex-shrink: 0后该场景失败scrolls: false6.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:builtpackage.json先整体构建再对构建产物做浏览器断言。lib/陈旧会让测试对着更旧的客户端断言。中途失败的pnpm run build留下的正是这种状态失败之前构建的那些包是新的其余不是在这种状态下刷新预期输出记录下来的是旧客户端的界面。抓取前应先确认构建以 0 退出packages/下的未跟踪目录同样会被编译来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。此外要复现这种空间不足需要的是矮视口而不是矮容器。高度上限是min(60vh, 520px)把会话列压到比卡片自身高度更矮只会裁剪卡片而不会让它空间不足——各行仍保持完整高度也不会有任何溢出。所以凡是在 e2e 场景之外演示或测量该缺陷的手段都必须改变视口这正是 e2e 里用page.setViewportSize而非改布局的原因。可迁移的三条布局原则把这条决策记录抽象出来它给出了三条可以复用到任何带高度上限的卡片的经验先回答高度不足由谁吸收在设有高度上限的 flex 卡片中滚动内容选项行与滚动容器.body的角色必须显式声明——内容侧flex-shrink: 0容器侧min-height: 0overflow-y: auto。默认flex-shrink: 1会让压缩静默落在内容上产生文案画出行盒、却没有滚动条的无声失效。警惕可见缺陷与无声缺陷的互换overflow: hidden、省略号、nowrap这类方案能消除重叠却把布局错误从看得见变成看不见在用户最需要阅读内容的紧张尺寸上悄悄丢信息。布局不变式的测试必须可证伪断言子元素不出行盒若配不上至少一行在折行和列表确实在滚动这两道守卫就会在 fixture 不折行时空洞通过因此录制的 fixture 描述故意比往返所需更长——测试数据的冗余不是浪费而是不变式能被触发的条件。【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考