DSH / Atlas
2026-07-30implementedbug-fix

The approval takeover shares the composer's text cap

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

The approval panel is a composer takeover: while a sandbox escalation waits, it replaces the InputBar in the composer seat with the model's justification, the paired command, and a refuse/allow row. Both texts are unbounded model output, and the card had no height cap. A long command — the realistic shape, since escalation happens on the command the sandbox just denied, and a denied command is often a long inline wri

English

Problem

The approval panel is a composer takeover: while a sandbox escalation waits, it replaces the InputBar in the composer seat with the model's justification, the paired command, and a refuse/allow row. Both texts are unbounded model output, and the card had no height cap. A long command — the realistic shape, since escalation happens on the command the sandbox just denied, and a denied command is often a long inline write — grew the card until the action row left the viewport. The user could read the request and not answer it: the buttons existed, off screen, in a sticky footer that had already used the whole column.

The InputBar the panel replaces has always been capped (14 lines, then the textarea scrolls), so the takeover was also the one composer state that could grow without limit — the seat's height jumped on election and jumped back on answer.

Decision

The panel's justification and command move into one scroll region (data-approval-scroll) capped at the same height as the composer's draft area; the amber strip and the action row sit outside it, so both buttons are in the card at every content length.

The cap is one value with two consumers, declared as --dsh-composer-text-max-height: 336px on ConversationRoot's .composerSeat — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. InputBar's draft scrollport and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is box-sizing: border-box so the cap is its outer height, the same box the composer's draft area occupies.

The region is a tab stop (tabIndex={0}, named role="group"). Unlike the question composer's scroll body, whose option rows are focusable and pull the container along, this one holds nothing but text: without its own tab stop a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading.

The panel's card rebinds --dsh-scrollbar-thumb{,-hover} to the l2 pair, as every scrolling surface on an elevated background must (scrollbar contract).

Alternatives considered

Cap the whole card instead of the text region. One declaration, no restructuring, and it reads as the literal "same max height as the input box". Rejected because the card holds the strip and the action row: at 336px total the justification and command would get ~250px, less room than the draft they replace, and the numbers would only agree by coincidence of the strip's height. Capping the text region makes both seats top out at the same text height, which is the property that keeps the footer from jumping.

Cap against the viewport like the question composer (min(60vh, 520px)). The sibling takeover already does this, so it is the local precedent. Rejected because the designer's request was parity with the InputBar, and the two takeovers are not the same shape: the question composer's scroll content is a list of options the user must compare, which wants as much viewport as it can get, while the approval panel's is one command the user skims before deciding. A viewport-relative cap would also make the seat's height jump on election again, in the other direction.

Ellipsize or truncate the command. No scroll region, no cap, and the buttons stay put. Rejected because the command is the thing being approved: hiding its tail asks the user to consent to text they cannot read. Truncation is also unrecoverable here — the panel is the whole approval UI, so there is no "show more" surface to fall back to.

Leave the action row inside the scroll region and cap the region. Fewer moving parts than pinning the row. Rejected because it reproduces the defect inside the card: the buttons scroll out of the region, and the user has to discover a scrollbar to reach them.

Consequences

  • A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports scrollHeight past clientHeight, and both buttons stay inside the card and inside the viewport.
  • Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves.
  • The InputBar's 14-line cap now resolves through a custom property inherited from .composerSeat, on the box that scrolls its draft (one scrollport for both text layers moved the declaration off the auto-grow mirror). Rendering the bar outside that seat would drop the declaration (an unresolved var() with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root.
  • The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into printf 'alpha %.0s' {1..400}, a one-line command that proves nothing).

Verification

apps/web/tests/approval-composer.e2e.ts drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live draft scrollport before sending rather than hardcoding the px value.

Confirmed both directions against the built client. With the cap reverted, the region reports scrolls: false and grows to the command's full height (1798px for the recorded blob at 900x1000, against 336px capped); at 900x700 the card is 680px tall against a 700px viewport and the action row's bottom lands at y=749 — below the fold, the designer's report exactly. With the cap restored the scenario passes in replay.

Reproducing the off-screen buttons needs a card taller than the scrollport, not merely a tall card. The composer seat is position: sticky; bottom: 0, so while the card still fits it stays pinned to the viewport bottom and the buttons remain visible — at 900x1000 the uncapped card ate the whole transcript yet kept its action row on screen. Only once the card outgrows the scrollport does sticky stop being able to hold the bottom edge, and the row goes under.

The geometry block and the golden are replay-only, so record mode reaches the fixture write instead of aborting on layout.

The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, DONE, the panel gone, the composer re-enabled). An answered-transcript golden cannot hold: the denied first attempt renders the OS's own refusal, and that text is platform-specific (bash: notes.txt: Operation not permitted on macOS against bash: line 1: notes.txt: Read-only file system on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden.

The panel ships as a client-module bundle: pnpm run build:web alone does not pick up a change to ApprovalPanel.module.css or a new data- hook in ApprovalPanel.tsx — the package build must run first, or the browser lane asserts against an older client than the tree.

中文

问题

审批面板是一次 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 立不住:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 bash: notes.txt: Operation not permitted,Linux 为 bash: line 1: notes.txt: Read-only file system)。任何 transcript 中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。

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