Files
deepseek-harness/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md
T

8.2 KiB
Raw Blame History

Agent Note: 审批接管面板与输入框共用同一文本高度上限

Status: implemented

English | 中文

问题

审批面板是一次 composer 接管:当一次沙箱越权申请处于等待状态时,它在 composer 容器中取代 InputBar,展示模型给出的理由、与之配对的命令,以及一行拒绝/允许按钮。这两段文本都是长度不受限的模型输出,而卡片当时没有任何高度上限。命令一长——而这正是现实中的常见形态,因为越权申请针对的就是沙箱刚刚拒绝的那条命令,而被拒绝的命令往往是一次很长的内联写入——卡片就会一直变高,直到操作按钮行离开视口。用户能读到这次申请,却无法回应它:按钮存在,只是在屏幕之外,位于一个已经占满整列的吸底容器里。

被它取代的 InputBar 一直是有上限的(14 行,之后由 textarea 自行滚动),因此这次接管也是 composer 唯一一个可以无限增高的状态——被选中时容器高度骤增,回应之后又骤降。

决策

面板的理由与命令移入同一个滚动区域(data-approval-scroll),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。

这个上限是一个值、两个消费者,以 --dsh-composer-text-max-height: 336px 声明在 ConversationRoot.composerSeat 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。InputBar 的草稿滚动容器与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的「可以跟输入框最大高度统一」,如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 box-sizing: border-box,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。

该区域自身是一个 Tab 停靠点(tabIndex={0},带名称的 role="group")。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。

面板卡片把 --dsh-scrollbar-thumb{,-hover} 重新绑定到 l2 那一对,这是每一个位于高层表面上的滚动区域都必须做的(滚动条约定)。

曾考虑的替代方案

给整张卡片设上限,而不是给文本区域设。 一条声明,不需要重构结构,而且它读起来就是字面意义上的「与输入框相同的最大高度」。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。

像提问 composer 那样按视口设上限(min(60vh, 520px))。 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。

对命令做省略号或截断处理。 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有「展开更多」的落脚处。

把操作按钮行留在滚动区域内,只给该区域设上限。 比把按钮行固定住少动几处。之所以否决:这会把缺陷搬进卡片内部——按钮滚出该区域,用户得先发现有滚动条才能碰到它们。

后果

  • 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 scrollHeight 超过 clientHeight,两个按钮都留在卡片内、也都留在视口内。
  • 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的 transcript(文本记录)不会有数百像素的重排。
  • InputBar 的 14 行上限现在通过一个自 .composerSeat 继承而来的自定义属性解析,且落在真正滚动草稿的那个盒子上(两层文本共用同一个滚动容器把该声明从自增高镜像层移了出去)。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 var()),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。
  • 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把「alpha 重复 400 次」写成了 printf 'alpha %.0s' {1..400},一条什么也证明不了的单行命令)。

验证

apps/web/tests/approval-composer.e2e.ts 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动的草稿滚动容器上读出,而不是把该像素值写死。

在构建产物客户端上双向确认过。撤销上限后,该区域报告 scrolls: false,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——位于视口下方,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。

要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 position: sticky; bottom: 0,因此在滚动视口尚能容纳卡片时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个 transcript,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入视口下方。

几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture(测试前置数据)那一步,而不是在布局检查处中断。

该场景只保留一份 golden——等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、DONE、面板消失、输入框重新可用)。最初还录了一份"已回应 transcript"的 golden,它在 Linux CI 上失败了:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 bash: notes.txt: Operation not permittedLinux 为 bash: line 1: notes.txt: Read-only file system)。任何 transcript 中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。

该面板以客户端模组包的形式发布:单跑 pnpm run build:web 不会带上对 ApprovalPanel.module.css 的改动,也不会带上 ApprovalPanel.tsx 中新增的 data- 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。