# Agent Note: composer 的两层文本共用同一个滚动容器

Status: implemented

[English](2026-07-31-composer-text-layers-share-one-scrollport.md) | 中文

## 问题

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

两层各自持有一个滚动偏移，会分两个阶段失效，本次改动是其中的第二个。

第一个阶段是静态的。backdrop 为 `position: absolute; inset: 0; overflow: hidden`——只裁剪、不滚动——浏览器也不会把它的偏移与 textarea 关联起来，于是超过上限之后光标在动而文字冻结在第 1 行。未达上限时这一点不可见，因为两层都停在 0；该缺陷与高度上限同龄，藏在所有截图所捕获的那个静止状态背后。当时的修复是用一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上，这让两层在**静止时**保持一致。

第二个阶段正是随后被用户报告的现象：从长草稿的顶部快速滑动时，光标像带着惯性一样从自己的文字里往上飞出去，过一会儿又落回原位。原因就是那个镜像。滚轮手势在合成线程上滚动 textarea，不经过主线程；而驱动赋值的 `scroll` 事件在其后才派发，因此中间这些帧里光标位于新偏移、每一个字形却仍位于旧偏移。在同一套几何的独立环境上实测：把偏移改变 200px 并在本任务结束之前读取光标与其字形之间的距离，chromium 分离 203px、firefox 202px、WebKit 203px，一两帧之后才收敛到固定的行盒常量（3/2/3）。每个引擎、每次手势都如此，且滑动越快分离越远。

任何监听都无法消除这个间隙，因为这个间隙正是监听的定义：它在自己所响应的那件事之后才运行。凡是用 JavaScript 维持两个盒子相等的做法，都会落后于那个不经询问就搬动其中一个盒子的合成器一帧。

## 决策

只保留一个滚动盒，让它同时装下两层。

`[data-input-scroll]` 是 composer 唯一的滚动容器，14 行的高度上限落在它身上。容器内部的自增高栈与**整份**草稿等高——隐藏的镜像层处于常规流中且不再设上限，因此由它把栈撑到完整文本高度——backdrop 与 textarea 以绝对定位骑在这个高度上。textarea 为 `overflow: hidden`，自身没有可滚动溢出，也就再无法持有任何偏移。

于是浏览器在同一帧、同一个合成器上，把同一个偏移施加给两层。光标与字形的绑定来自结构本身，而不是来自持续维护：没有代码要跑，没有事件要等，也没有任何状态可能落后一帧。滚轮接力处理器保留，只是从 textarea 改挂到滚动容器上，并且仍是这个盒子上唯一的监听。

Safari 的原生文本控件存在一个引擎例外：跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.md)会在绘制前恢复零溢出不变量，而不改变单滚动容器设计。

上一版机制所需要的两样东西随它一起消失：

**backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒，而 `white-space: pre-wrap` 会折叠文本节点的尾随换行，因此以换行结尾的草稿会让 backdrop 少一行，把镜像偏移钳制在光标上方一行。改为单一滚动容器后，backdrop 自身的范围不再决定任何事：镜像层为两层统一定高，两层顶端对齐，内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制：正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。

**折行宽度这一前提。** 现在三层都在滚动容器内部解析自身宽度，因此一条占布局宽度的滚动条对它们的代价由结构保证相等。这也就关闭了被取代的那篇笔记记录为「悬置且没有任何属性能修」的分歧：WebKit 会为 `overflow-y: auto` 的 textarea 预留槽位，却不为它旁边 `overflow: hidden` 的层预留，把 textarea 排成 768 对 776——在长草稿上值 2 到 5 个折行，也就是在唯一能观察到它的那个引擎上把字形放到了错误的光标之下。改动后在独立环境实测，Playwright 自带的三个引擎上三层宽度均一致。

**由 composer 自己完成的编辑，现在会主动请求回视。** 粘贴与剪切都会抑制原生编辑——草稿与撤销日志归状态机所有——再用 `setSelectionRange` 恢复光标，而这不会带来任何回视：在 chromium 与 WebKit 上实测，粘贴一大段之后视图停在原处，光标却落在所粘内容的末尾。该缺陷早于本次改动（Firefox 只在旧几何下恰好会回视），在此修复，是因为单一滚动容器才终于让「回视」成为我们能自己做的事。两处恢复共用一个 helper：它以隐藏的镜像层为标尺——同一份草稿、同一套度量、同一折行宽度，因此在光标索引处折叠一个 Range 就能报出光标位置，无需任何 caret API——并且只滚动到刚好把该行带进可见范围为止，与浏览器为输入所做的一致。

有一种形状需要单独的规则，因为引擎之间在这里并不一致：紧跟在换行之后的光标，落在一条没有任何内容可供度量的行上——以换行结尾的草稿正是终止于此。chromium 对这个折叠位置**根本不返回任何 client rect**（一个全零盒子，会把回视带向反方向），firefox 报的是上一行，WebKit 报的才是对的那一行。因此该 helper 改为度量光标刚离开的那个换行——它的盒子就是光标来的那一行——再往下走一行；三者随即落在同一个偏移上（649/652，光标所在行位于 336px 盒内的 315）。即使草稿以连续换行结尾，覆盖该换行的非折叠 Range 在三个引擎上都会返回真实矩形，因此每个尾随空行都能沿用同一条规则定位。

现在唯一依赖浏览器而非依赖我们自己的，是把光标滚入可见范围：textarea 没有了自己的偏移，它的 scroll-into-view 必须向上走到滚动容器。实测的每个引擎都会这么做——在草稿末尾输入会把滚动容器带到光标处（chromium、firefox、WebKit 分别为 625、626、628，最大值 628），用 `ArrowUp` 把光标一路走回去会滚回去，滚离光标后再输入也会回到光标。

## 备选方案

**用 `scroll` 监听把 `scrollTop` 镜像到 backdrop 上。** 被取代的那个决策，静止时是正确的：正是它让长草稿第一次可以滚动。现在被否决，是因为它在运动中不可能正确——它是主线程对合成线程事实的响应——并且它需要两个前提持续成立（范围相等、折行宽度相等），而单一滚动容器根本不需要这两个前提，其中每一个都已经失效过一次。

**用 `transform: translateY(-scrollTop)` 平移 backdrop，而不是赋一个偏移。** 延迟相同：仍在主线程，仍由同一个事件驱动。它还会把范围分歧盖住而不是让两层真正一致，于是只要有什么东西去测量 backdrop，错配就会重新浮现。

**用滚动驱动动画（`animation-timeline: scroll()`）驱动 backdrop。** 这会把耦合放到合成器上运行，在保留两个盒子的前提下确实能消除延迟。因支持度被否决：Safari 尚未实现、Firefox 也是近期才有，因此在缺少它的引擎上 composer 会保留被报告的缺陷，而其回退路径正是这次要替换掉的机制。

**两层都由 JavaScript 驱动滚动：textarea 设 `overflow: hidden`，滚轮处理器在同一个任务里给两个偏移赋值。** 滚轮手势期间不会分离，因为没有我们就没有东西会滚动。被否决，是因为它用手写近似替换了原生滚动——惯性、触控板回弹、拖拽滚动条、键盘滚动——而且光标回视路径（浏览器设置 textarea 自己的偏移）仍然是异步落地的。

**把上限留在镜像层上，只在今天的结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」：绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的，而不是相对其可滚动溢出区域，于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高，这套排布才有意义。

**给 backdrop 加 `overflow: auto`，让它自己滚动。** 那样它就有了一个自己的偏移需要保持同步，即同一个问题再加一条画在输入框上的滚动条。backdrop 是 textarea 的投影，不是一个可独立导航的界面。

**去掉 backdrop，直接给 textarea 自己的文本上样式。** 这会消除分层，也一并消除这一整类失步。被否决是因为它不可实现：textarea 只渲染一段统一的文本，claim token 高亮、chip 与提示影子文本——backdrop 存在的理由——无从表达。为修滚动而失去它们，是拿一个有界缺陷换一次功能删除。

**用 `contenteditable` div 渲染草稿。** 一个元素、一个偏移、区间可上样式。因代价与缺陷严重不成比例被否决：`contenteditable` 会把输入法组合、撤销/重做、选区语义与粘贴规范化重新压回我们身上，而这些目前都由 textarea 加输入状态机处理，且状态机已经持有一份假定 textarea 值语义的撤销日志。

**给三层都加 `scrollbar-gutter: stable`。** 在 textarea 还是滚动者时试过并已回退：WebKit 只对 `overflow-y: auto` 生效、不对 `overflow: hidden` 生效，那 8px 的差距原样留着，却让每个 chromium 用户无条件损失 8px 文本列宽。现在已无意义——三层共享同一包含块，没有什么需要预留。

**给滚动的那一层加 `scrollbar-width: none`。** 靠隐藏滑块来抹平宽度。被否决：草稿超过上限时 composer 是有意显示滑块的——`.card` 绑定 l2 滚动条 token 正是为此——而它是唯一提示「长草稿在下面还有」的可供性。

## 影响

- 光标不可能离开自己的字形。浏览器滚动的是同一个盒子，因此「textarea 把某一行放在哪」与「backdrop 把这一行画在哪」之间的距离在任何偏移下都是一个固定的行盒常量，手势进行中也不例外。场景测试度量的正是这个数。
- 滚动条从 textarea 移到了滚动容器上——视觉位置相同，只是外移了一层。`.card` 的 l2 token 绑定仍会继承下去。
- chip、claim token 高亮与文本引用标记在滚动时仍与其字形对齐，因为它们定位在 backdrop 内部、随之移动。除去掉哨兵之外，装饰扫描没有变化。
- 在 Firefox 与 WebKit 上，点进一个草稿超过上限的 composer 现在还会把会话 transcript（文本记录）滚动到底部：光标的 scroll-into-view 会越过 composer 的滚动容器一路走到 transcript 的滚动容器，而一个比自身盒子矮的 textarea 从不会引发这一步。chromium 不会。已实测，并试过 `overscroll-behavior: contain` 与 `contain: paint`，两者都拦不住这次上行——没有任何 CSS 能终止 scroll-into-view 的接力。接受：它滚向底部，而 composer 本来就在底部，而其替代方案是在每个引擎上都出现光标与文字明显分离。
- 翻页与拖拽选区的行为未变，二者均做了新旧对照实测。`PageDown`/`PageUp` 本来就不会移动 textarea 的插入点——chromium 是滚动一页并保持 `selectionStart` 不变，新旧几何皆然，区别只在于滚的是哪个盒子。拖拽选区越过下边缘仍会自动滚动，且落点一致（chromium 628/628、firefox 625/620、WebKit 170/170——WebKit 自动滚动较慢，但改动前后一样慢）。
- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect，以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦，不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后，光标就回到了我们手上，而这在一条路径上确实要紧：composer 的 DOM 跨会话复用，因此切到更长的草稿时旧偏移会留着，而换值会把光标放到新草稿的末尾。三引擎实测，这会让光标落在停在 0 的盒子下方 940px 处；于是该 effect 会在自己的滚动容器里把它带回来，落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视：光标没动过，而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿：`ConversationSession` 在自己的 mount effect 中注入持久化草稿，而该 effect 在本组件的 effect 之后运行，因此如果没有这个独立 effect，第一次回视会量到空镜像，且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦，因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。
- 撤销/重做可以在不恢复光标、也不回视的情况下改动草稿：状态机重放上一版草稿，DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名，是因为另外两处恢复都会回视，会让这处遗漏看起来像有意为之；真被报告时 helper 就在旁边。
- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**，并且必须与草稿等高，否则就会重新引入这一缺陷。这是 composer 长期存在的风险点：两层拆分对 chip 与高亮是承重的，因此耦合必须来自结构，而不是靠维护。

## 测试

[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 中的单元用例断言 jsdom 能看见的部分：同一个滚动盒同时包含 textarea 与 backdrop，backdrop 的文本现在就是草稿本身、不多不少，且渲染后才到达的持久化草稿会回视其光标，同时不从其他控件夺走焦点。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动，因此几何属于浏览器场景；滚轮接力用例改为桩接滚动容器的度量，而非 textarea 的。

[composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物度量其余部分：全新工作区的空白 composer 中一份 40 行草稿，零模型调用。每个度量都在光标自己的坐标系里读取——即 textarea 把第 n 行放在哪，含其自身偏移——再与 backdrop 同一行文本上的 DOM Range 相比，因为这个差值正是用户看到的东西。决定性的用例改变偏移，并**在本任务结束之前**重新读取该差值，也就是在任何 `scroll` 监听可能运行之前：单一滚动容器下为 0，镜像方案下则是整个增量。空洞性保护先断言草稿确实超过了带上限的盒子；其余用例分别覆盖高度上限、三层同一折行宽度、滚轮手势、以换行结尾的草稿，以及过去由 textarea 自身滚动承担的光标回视路径——滚离光标后输入，必须把滚动容器带回光标处。

另有一个用例端到端覆盖粘贴路径：短草稿、光标停在末尾，然后派发一个携带真实剪贴板数据的 `paste` 事件——与 Cmd-V 送达的是同一个事件，走同一个处理器——再检查偏移与所粘内容的最后一行。它等待的是偏移而不是「草稿是否溢出」，因为恢复发生在状态机提交草稿之后的下一帧；没有这次回视的构建会卡在这个等待上失败。

支撑该决策的两套几何对比是在实现之前于独立环境度量的，因为新旧排布无法在应用里同时存在：同任务分离度旧为 203/202/203px、新为 3/2/3px（chromium/firefox/WebKit），折行宽度旧在 WebKit 上为 768 对 776、新在三个引擎上均为 1264/1264/1264，而新几何下 textarea 自身的可滚动溢出为 0——正是这一点让第二个偏移不可能存在，而不只是碰巧相等。

注意 composer 打包在 client-module bundle 内，因此只跑 `pnpm run build:web` 并不会带上 `InputBar.tsx` 的改动——必须先跑包构建，浏览器泳道才看得到；对着过期 `lib/` 跑场景，断言的是比当前代码树更旧的客户端。
