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

Status: implemented

[English](2026-07-30-approval-panel-command-cap.md) | 中文

## 问题

审批面板是一次 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 那一对，这是每一个位于高层表面上的滚动区域都必须做的（[滚动条约定](../../../../packages/client/ui-theme/src/styles/scrollbar.css)）。

## 曾考虑的替代方案

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

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

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

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

## 后果

- 长命令在卡片内滚动，拒绝／允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测：该区域报告的 `scrollHeight` 超过 `clientHeight`，两个按钮都留在卡片内、也都留在视口内。
- 选中接管面板不再改变 composer 容器能达到的高度，因此审批到来或解决时，上方的会话流不会有数百像素的重排。
- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析，且落在真正滚动草稿的那个盒子上（[两层文本共用同一个滚动容器](2026-07-31-composer-text-layers-share-one-scrollport.md)把该声明从自增高镜像层移了出去）。把输入栏渲染到该容器之外会丢掉这条声明（一个没有兜底值的未解析 `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 下，未设上限的卡片吃掉了整个会话流，却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口，sticky 才再也无法守住底边，按钮行随之沉入折叠之下。

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

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

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