# Agent Note: composer 的字形层跟随 textarea 的滚动偏移

Status: implemented

[English](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) | 中文

## 问题

草稿一旦超过 14 行的高度上限，就无法再滚动。光标会动，选区会动，但文字始终冻结在第 1 行——无论滚轮、拖拽还是方向键，都无法把长草稿的末尾带到可见范围内，因此约 14 行之后的内容在书写过程中既够不着也读不到。

高度上限本身是正常工作的。composer 的文本由两层叠放绘制（见 [InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)）：`<textarea>` 持有取值、选区与光标，但它自己的字形以 `color: transparent` 渲染；用户看到的每一个字符都由其下的 `[data-input-backdrop]` 层绘制，该层同时承载 claim token 高亮、chip 与提示影子文本。这一拆分正是 chip 与高亮得以存在的前提——textarea 无法为自身文本的某个区间单独设置样式。

两层在几何上是耦合的，在滚动上却不是。backdrop 为 `position: absolute; inset: 0; overflow: hidden`：它只做裁剪，不做滚动，浏览器也不会把它的偏移与 textarea 关联起来。未达上限时这一点不可见，因为两层都停在偏移 0，且镜像层会把盒子撑到草稿的高度。一旦触及上限，textarea 开始滚动而 backdrop 不跟随，于是用户真正在读的那一层从不移动。

因此该缺陷与高度上限同龄，并且藏在静止状态背后：短草稿——也就是所有截图与既有 fixture（测试前置数据）所捕获的那个状态——在有无该耦合时渲染完全一致。

## 决策

`InputBar` 通过一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上，该监听与既有的滚轮接力监听注册在同一个 effect 中（textarea 从不卸载——失效状态渲染的是同一个元素的 disabled 形态）。

一个监听即构成完整耦合，因为这个盒子移动的每一种方式最终都会在 textarea 上产生 `scroll` 事件：手势使它滚动；编辑会把光标滚入可见范围；草稿缩短到当前偏移之下时它会被钳位。看似需要单独处理、实则不需要的正是钳位这一种：两层共享同一滚动范围，因此它们会钳位到同一个最大值，而 textarea 的钳位本身就会触发那次完成镜像的 `scroll`。

这个「共享的滚动范围」并非白得，而镜像偏移只有在它成立时才是正确的。有两件事会破坏它，都是在审查中被发现的，且失效方向相同——backdrop 比 textarea 矮，于是赋值被钳制、字形落到光标之下。textarea 会在末尾换行之后为光标保留一个行盒，而 `white-space: pre-wrap` 会折叠文本节点的尾随换行、不生成任何行盒。因此以换行结尾的草稿会让 backdrop 恰好比 textarea 少一行——实测为 628 对 652——于是该赋值被钳制，滚到最底部时字形比光标落后一行。现在 backdrop 也带上了镜像层早已具备的同一枚尾行哨兵：其内容为装饰扫描的结果再加一个 `'\n'`；草稿不以换行结尾时它被同一次折叠吸收，以换行结尾时它补上缺失的那个行盒。对纯文本、尾随换行、软折行、不可断长串以及中间空行五类草稿实测，两侧范围在每种情形下均相等。

第二个前提是折行宽度，它是被断言的，而不是被修复的。只有 `.input` 会滚动，因此也只有 `.input` 会把内容宽度让给一条占布局宽度的滚动条；`.input` 一旦更窄，长草稿就会折出更多行——在独立环境实测，8px 的宽度差值 2 到 5 行，而宽度相等时 textarea 与 div 完全一致。在运行中的应用上、对 Playwright 自带的三个引擎实测，两个相等、一个不等：

| 引擎 | `.input` / `.backdrop` / `.mirror` 折行宽度 | 滚动范围 |
|---|---|---|
| chromium | 776 / 776 / 776 | 相等 |
| firefox | 776 / 776 / 776 | 相等 |
| WebKit | **768** / 776 / 776 | 所测草稿下相等 |

WebKit 的 textarea 把 8px 让给了自己的滚动条，而两个被裁剪的图层没有。该差距先于本次改动存在，本 PR 未予关闭；在所测草稿下滚动范围仍然相等，因此镜像不受影响，但一份恰好在该宽度上折行敏感的草稿会让 `.input` 更高、从而钳制镜像偏移。场景在测试通道所用引擎上断言了这项相等性，因此一旦回退到那种状态会显式失败，而不是悄然发生。

共享度量块上的 `scrollbar-gutter: stable` 曾被采用又被移除：WebKit 对 `overflow-y: auto` 应用它、对 `overflow: hidden` 不应用，于是 `.input` 仍是 768 对 776——正是它本想关闭的那个差距——同时又让 chromium 无条件损失 8px 文本宽度。要关闭它，需要一套所有引擎都认同的几何，而不是这个属性。

该镜像是单向的：textarea 是权威方，因为它持有光标，而浏览器滚动的目标正是光标。

## 曾考虑的替代方案

**给 backdrop 加 `overflow: auto`，让它自行滚动。** 那样它就有了一个属于自己的滚动偏移需要同步，问题原样保留，还额外多出一条画在输入框上的滚动条。backdrop 是 textarea 的投影，而不是一个可独立导航的界面。

**去掉 backdrop，直接为 textarea 自身文本设置样式。** 这会消除分层，连同整类失步问题一并消除。之所以否决，是因为它根本无法实现：textarea 只渲染一段统一的文本流，因此 claim token 高亮、chip 与提示影子文本——backdrop 存在的全部理由——都无从表达。为修滚动而放弃它们，是拿一个有界的缺陷去换一次功能删除。

**改用 `contenteditable` div 承载草稿，不再用 textarea。** 一个元素、一个滚动偏移、区间可设样式。之所以否决，是它与该缺陷的体量严重不相称：`contenteditable` 会把 IME 组词、撤销／重做、选区语义与粘贴规范化重新压回我们身上，而这些目前都由 textarea 加输入状态机处理，且状态机已持有一份以 textarea 取值语义为前提的撤销日志。

**在既有的滚轮处理函数里滚动 backdrop，而不是新增 `scroll` 监听。** 该处理函数本就在 textarea 上的每次滚轮时运行，看似是自然的落点。之所以否决，是它只覆盖了盒子滚动的其中一种成因：在末尾输入、`End`、方向键、拖选越过边缘、拖动滚动条，都会在没有滚轮事件的情况下移动 textarea。监听 `scroll` 是在监听事情本身，而不是它的某一个成因。

**用 `scrollbar-gutter: stable` 让三层一起预留滚动条 gutter。** 曾经采用，实测后回退。当初的推理是：无论平台滚动条占多少宽度，三层都预留同样多即可保持相等；而且 `overflow: hidden` 也是滚动容器，按规范应当遵守该声明。chromium 确实如此（三层各预留 8px，宽度 768/768/768）。WebKit 不然：它对 `overflow-y: auto` 预留、对 `overflow: hidden` 不预留，结果仍是 768 对 776——差距原样保留——于是该属性在唯一能观测到这一偏差的引擎上一无所获，却让每一位 chromium 用户损失 8px 文本列。改为断言该前提并记录 WebKit 的差距。

**改为抑制 textarea 的滚动条，而不是给另外两层预留 gutter。** 在 `.input` 上写 `scrollbar-width: none` 同样能让宽度相等，且不必收窄文本列。之所以否决：草稿超过上限后 composer 是有意显示滚动条滑块的——`.card` 正是为此绑定了 l2 滚动条 token——去掉它就等于拿走了「下面还有内容」这一唯一提示。

**改用 `transform: translateY(-scrollTop)` 平移 backdrop，而不是滚动它。** transform 不受内容高度钳制，因此它能把任何范围偏差——包括尾随换行这一种——一并掩盖，却并不让两层真正对齐。之所以否决，是因为这个偏差本身就是真正的缺陷：范围不等同时意味着两层对末行位置的判断不一致，把它藏在一个不受钳制的 transform 之后，只会让这一失配在任何人去测量 backdrop 的那一刻重新浮现。修正范围本身，才能让草稿高度只有一个事实来源。

**再加一个以已提交草稿为 key 的 layout effect 作为第二道镜像。** 该改动的第一版确实带着它，理由是：一次编辑会让两层重排却不一定让 textarea 移动，且草稿变短时两层各自独立地被钳位。这两个前提都不成立，因此在针对构建产物客户端逐个变异测试每个 hook 之后将其移除：仅禁用 layout effect 时浏览器场景全绿，而仅禁用 `scroll` 监听则会失败。输入会把光标滚入可见范围，那就是一次普通的 `scroll`；草稿变短时两层因范围相等而钳位到同一个最大值，且 textarea 的钳位同样会触发 `scroll`。该 effect 本想覆盖的那个具体隐患——React 在装饰集合形状变化时替换 backdrop 的全部子节点，从而重置其偏移——并不会发生：在 chromium 中实测，替换一个 `overflow: hidden` 盒子的全部子节点会保留 `scrollTop`（300 仍为 300），唯一会将其归零的替换是把内容缩短到偏移之下，而那正是已被覆盖的钳位情形。

**在 `onChange` 处理函数里同步。** 除上述同样的理由外还有其自身的问题：它在 React 把新草稿提交到 backdrop 之前触发，因而会按上一次的布局做镜像。

## 后果

- 超过上限的草稿会滚动其字形。浏览器场景实测：在 40 行草稿上做一次滚轮手势后，最后一行位于可见盒子之内，第一行已滚出上方；此前最后一行仍停在盒子下方整整一个草稿高度处，而 textarea 自身的偏移已经移动了。
- 该耦合是单向且廉价的——一次对一个数字的赋值，没有测量，除 `scrollTop` 外没有额外的布局读取——因此不会给输入路径增加开销。
- chip、claim token 高亮与文本引用标记在滚动时始终与其字形对齐，因为它们定位在 backdrop 内部并随之移动。装饰扫描本身没有任何改动。
- composer 的双层设计保留了这一隐患：日后在 backdrop 旁新增的任何一层都需要同样的镜像；而任何改变某一层如何保留其末行行盒的改动，都会破坏镜像所依赖的范围相等性。e2e 场景对两者都做了断言——用户真正关心的关系（哪一行在屏幕上），以及其下的范围相等性——因此日后一旦出现偏差，失败会落在不变量上，而不是落在某张截图上。
- 范围相等性是被断言的，而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的前提，并且在加入哨兵之前，它在尾随换行这一形态上确实不成立。
- 折行宽度相等是另一个前提，而它并非普遍成立：WebKit 把 `.input` 排得比字形层窄 8px。该问题先于本次改动存在，此处保持开放，上文记录了实测数值，并在测试通道所用引擎上加了断言。一份折行恰好取决于这 8px 的草稿会在 WebKit 上钳制镜像。
- composer 的布局没有变化。此前有一版为追求折行宽度前提而在所有平台把文本列收窄了 8px；实测表明它并不能带来该保证，因此度量与改动前保持一致。

## 验证

[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例证明镜像路径确实执行：它对两侧偏移都做了桩替换——因为 jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动任何元素——并断言 backdrop 既跟随 textarea 到新的偏移，也跟随它回到顶部。撤掉那个 `ref` 会让它失败。

用户可见的事实需要真实引擎，因此 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物客户端测量它：在全新工作区空白会话的 composer 中放入 40 行草稿，零模型调用，用一个跨越 backdrop 自身文本的 DOM Range 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。另有一个独立用例驱动尾随换行这一形态，先断言两侧范围相等，再断言字形确实抵达末尾；每一层的最大值都通过请求一个不可能的偏移再读回其钳位结果来观测，而非由 `scrollHeight` 计算得出。第三个用例断言 gutter 前提：折行宽度相等，且每层预留的带宽大于零。正是这条「带宽」使该断言不至于空转——在本引擎的 overlay 滚动条下，即使完全不预留，两侧宽度也会相等；把保证传递到滚动条真正占宽的平台上的，是那次预留，而不是这次相等。

已双向确认。撤掉镜像并重新构建各包后，滚轮用例在两层偏移上失败，输入用例随之失败，golden 差异读作 `last draft line is on screen: false` 而 `textarea moved: true`——即以 fixture（测试前置数据）形式陈述的原始现象。静止状态用例在两种构建下都通过，这正是要点所在：它就是掩盖了该缺陷的那个状态。

注意 composer 随客户端模块 bundle 一同发布，因此仅运行 `pnpm run build:web` 不会纳入对 `InputBar.tsx` 的改动——必须运行包构建，浏览器测试通道才能看到它；针对陈旧 `lib/` 运行的场景，断言的是比当前工作树更旧的客户端。
