The composer's two text layers share one scrollport
composer 的两层文本共用同一个滚动容器
The composer paints its text in two stacked layers ([InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)): the `<textarea>` owns the value, the selection, and the caret but renders its own glyphs `color: transparent`, and every visible character is painted by the `[data-input-backdrop]` div beneath it, which also carries the claim-token highlight, the chips, and the ghost hint. Tha
English
Problem
The composer paints its text in two stacked layers (InputBar): the <textarea> owns the value, the selection, and the caret but renders its own glyphs color: transparent, and every visible character is painted by the [data-input-backdrop] div beneath it, which also carries the claim-token highlight, the chips, and the ghost hint. That split is what makes chips and highlights possible at all — a textarea cannot style a range of its own text. The draft box is capped at 14 lines, so past the cap something has to scroll.
Two layers with two scroll offsets fail in two stages, and this change is the second one.
The first was static. The backdrop is position: absolute; inset: 0; overflow: hidden — clipped, not scrolled — and nothing in the browser links its offset to the textarea's, so past the cap the caret moved and the words stayed frozen at line 1. Below the cap that is invisible, because both layers rest at 0; the defect was exactly as old as the cap and hid behind the resting state that every screenshot captured. It was fixed by mirroring the textarea's scrollTop onto the backdrop from a scroll listener, and that made the two layers agree at rest.
The second is what a user then reported: swiping quickly from the top of a long draft throws the caret up out of its own text, as though it carried momentum, and it settles back a moment later. The mirror is the cause. A wheel gesture scrolls the textarea on the compositor, off the main thread; the scroll event that drives the assignment is dispatched afterwards, so for the frames in between the caret sits at the new offset and every glyph sits at the old one. Measured on a standalone harness of the same geometry, moving the offset 200px and reading the caret's distance to its own glyphs before the task ends: chromium 203px, firefox 202px, WebKit 203px of separation, settling to the fixed line-box constant (3/2/3) a frame or two later. Every engine, every gesture, proportional to how fast the user scrolls.
No listener can close that gap, because the gap is the definition of a listener: it runs after the thing it reacts to. Anything that keeps two boxes equal in JavaScript is a frame behind a compositor that moves one of them without asking.
Decision
One scrolling box, holding both layers.
[data-input-scroll] is the composer's only scrollport and carries the 14-line cap. Inside it, the auto-grow stack is as tall as the whole draft — the hidden mirror div is in normal flow and no longer capped, so it sizes the stack to the full text — and the backdrop and textarea ride that height absolutely. The textarea is overflow: hidden with no scrollable overflow of its own; it can no longer hold an offset at all.
The browser then applies one offset to both layers, in the same frame, on the same compositor. The caret is bound to its glyphs by construction rather than by upkeep: there is no code to run, no event to wait for, and no state that can be one frame stale. The wheel-chaining handler stays, retargeted from the textarea to the scrollport, and remains the only listener on the box.
Safari's native text control has one engine exception: deleting across a soft-wrap threshold can retain the former line layout after the mirror shrinks. The Safari soft-wrap recovery restores the zero-overflow invariant before paint without changing the one-scrollport design.
Two things the previous mechanism needed are gone with it:
The backdrop's trailing-line sentinel. It existed to keep the two boxes' scroll extents equal — a textarea reserves a line box for the caret after a final newline while white-space: pre-wrap collapses a text node's trailing newline, so a draft ending in a newline made the backdrop one line shorter and clamped the mirrored offset a line above the caret. With one scrollport the backdrop's own extent decides nothing: the mirror div sizes the stack for both layers, both start at the same top, and a layer whose content ends earlier simply paints nothing on the last line. The shape is worth keeping in mind rather than the mechanism: it is the one that measured 628 against 652 when the two boxes had to agree on a height.
The wrap-width premise. All three layers now resolve their width inside the scrollport, so a scrollbar that consumes layout space costs them the same width by construction. This closes the divergence the superseded note recorded as open and unfixable by any property: WebKit reserved gutter space for the overflow-y: auto textarea and not for the overflow: hidden layers beside it, laying the textarea out 768 against 776 — worth 2 to 5 wrapped lines on a long draft, i.e. glyphs under the wrong caret on the one engine where it was observable. Measured on the harness after the change, all three layers report one width on all three engines Playwright ships.
Edits the composer performs itself now ask for the reveal. Paste and cut suppress the native edit — the machine owns the draft and the undo log — and restore the caret with setSelectionRange, which reveals nothing: measured in chromium and WebKit, pasting a long block leaves the view where it was while the caret sits at the end of what was pasted. That defect predates this change (Firefox happened to reveal it, in the old geometry only) and is fixed here because one scrollport is what finally makes the reveal ours to perform. The two restores share one helper that measures the caret against the hidden mirror — same draft, same metrics, same wrap width, so a Range collapsed at the caret's index reports where the caret is without a caret API — and scrolls the minimum that brings it inside, which is what the browser does for typing.
One shape needs a rule of its own, because the engines disagree about it: a caret straight after a newline sits on a line with nothing on it to measure, which is where a trailing-newline draft ends. chromium returns no client rects at all for the collapsed position — an all-zero box, which would send the reveal the wrong way — firefox reports the line above, and WebKit the right one. The helper therefore measures the newline the caret just left, whose box is the line it came from, and steps one line down; all three then land on the same offset (649 of 652, with the caret's line at 315 inside the 336px box). A non-collapsed Range over that newline returns a real rectangle on all three engines even when the draft ends in consecutive newlines, so each trailing blank line composes with the same rule.
Revealing the caret is the one thing that now depends on the browser rather than on us: with no offset of its own, the textarea's scroll-into-view has to walk up to the scrollport. It does, on every engine measured — typing at the draft's end brings the scrollport to the caret (625, 626 and 628 of a 628px maximum in chromium, firefox and WebKit), walking the caret back up with ArrowUp scrolls back to it, and typing after scrolling away returns to it.
Alternatives considered
Mirror scrollTop onto the backdrop from a scroll listener. The superseded decision, and correct at rest: it is what made a long draft scrollable at all. Rejected now because it cannot be correct in motion — it is a main-thread reaction to a compositor-thread fact — and because it needed two premises to stay true that the single scrollport does not need at all (equal extents, equal wrap widths), each of which had already failed once.
Translate the backdrop with transform: translateY(-scrollTop) instead of assigning an offset. Same lag: still main-thread, still driven by the same event. It additionally papers over extent divergence rather than making the layers agree, so a mismatch resurfaces the moment anything measures the backdrop.
Drive the backdrop from a scroll-driven animation (animation-timeline: scroll()). This would run the coupling on the compositor and genuinely eliminate the lag while keeping two boxes. Rejected on support: Safari does not implement it and Firefox has only recently, so the composer would keep the reported defect on the engines that lack it, and the fallback path would be the mechanism being replaced.
Scroll both layers from JavaScript, with the textarea overflow: hidden and a wheel handler assigning both offsets in one task. No divergence during wheel gestures, since nothing scrolls without us. Rejected because it replaces native scrolling — momentum, trackpad rubber-banding, scrollbar dragging, keyboard scrolling — with a hand-written approximation, and the caret-reveal path (the browser setting the textarea's own offset) still lands asynchronously.
Keep the cap on the mirror and just wrap today's structure in a scroller. The layers would stay window-sized, not draft-sized: inset: 0 on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything.
Give the backdrop overflow: auto and let it scroll itself. It would then have an offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface.
Drop the backdrop and style the textarea's own text. This removes the layer split and the whole class of desync with it. Rejected because it is not implementable: a textarea renders one uniform text run, so the claim-token highlight, the chips, and the ghost hint — the reasons the backdrop exists — have no way to be expressed. Losing them to fix scrolling trades a bounded defect for a feature deletion.
Render the draft in a contenteditable div. One element, one offset, styleable ranges. Rejected as far out of proportion: contenteditable would put IME composition, undo/redo, selection semantics, and paste normalization back on us, all of which the textarea plus the input machine currently handle, and the machine already owns an undo log that assumes a textarea's value semantics.
scrollbar-gutter: stable on all three layers. Tried and reverted while the textarea was the scroller: WebKit applied it to overflow-y: auto and not to overflow: hidden, leaving the same 8px gap unclosed while costing every chromium user 8px of text column. Moot now — the layers share a containing block, so there is nothing to reserve.
scrollbar-width: none on the scrolling layer. Would equalize widths by hiding the thumb. Rejected: the composer deliberately shows one once the draft passes the cap — .card binds the l2 scrollbar tokens for exactly that — and it is the only affordance saying a long draft continues below.
Consequences
- The caret cannot leave its glyphs. The browser scrolls one box, so the separation between where the textarea puts a line and where the backdrop paints it is a fixed line-box constant at every offset, mid-gesture included. The scenario measures exactly that number.
- The scrollbar moved from the textarea to the scrollport — the same visual place, one box out. The
.cardl2 token binding still inherits down to it. - Chips, claim-token highlights, and text-ref marks stay aligned with their glyphs while scrolled, because they are positioned inside the backdrop and move with it. The decoration walk is unchanged apart from the dropped sentinel.
- On Firefox and WebKit, clicking into a composer whose draft overflows the cap now also scrolls the conversation transcript to its bottom: the caret's scroll-into-view walks past the composer's scrollport up to the transcript scrollport, which a textarea shorter than its box never made it do. Chromium does not. Measured, with
overscroll-behavior: containandcontain: paintboth tried and neither stopping the walk — there is no CSS that ends scroll-into-view chaining. Accepted: it scrolls toward the bottom, where the composer already sits, and the alternative is a caret visibly detached from its text on every engine. - Paging and drag-selection are unchanged, both measured old against new.
PageDown/PageUpnever moved a textarea's caret in the first place — chromium scrolls a page and leavesselectionStartwhere it was, in both geometries; only the box that scrolls differs. Drag-selecting past the bottom edge still auto-scrolls, and to the same place (chromium 628/628, firefox 625/620, WebKit 170/170 — WebKit's slower autoscroll is equally slow before and after). - The composer's own
focus()calls passpreventScroll— the unlock/session-switch effect and the focus-keeping mousedown on the toolbar buttons — so a focus nobody gestured for cannot move the transcript through the taller textarea's reveal chain. Suppressing that walk hands the caret back to us on the one path where it matters: the composer DOM is reused across sessions, so switching to a longer draft keeps the previous offset while the value swap puts the caret at the new draft's end. Measured on all three engines, that leaves the caret 940px below a box sitting at 0; the effect therefore reveals it in its own scrollport, landing at 625 of 628 — what the old geometry reached at 628 through the browser. The mousedown path needs no reveal: the caret has not moved, and the next keystroke gets the browser's native one. A separate reveal-only effect handles a non-empty draft that arrives after render:ConversationSessionseeds a persisted draft in its own mount effect, which runs AFTER this component's, so the first reveal would otherwise measure an empty mirror and never run again for the draft that then appeared. The second effect never focuses, so ordinary empty/non-empty transitions such as send-clear or failed-send restore cannot take focus from another control. - Undo and redo can change the draft without a caret restore or reveal: the machine replays a previous draft and the DOM selection stays where the browser clamps it. That predates this change and is unchanged by it — named here because the two restores that DO reveal make the omission look deliberate, and the helper is sitting right there if it is ever reported.
- Any layer added beside the backdrop belongs INSIDE the scrollport and must be as tall as the draft, or it reintroduces exactly this defect. This is the composer's standing hazard: the two-layer split is load-bearing for chips and highlights, so the coupling has to be structural, not maintained.
Testing
The unit spec in input-bar.spec.tsx asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, that the backdrop's text is now the draft and nothing else, and that a late persisted draft reveals its caret without taking focus from another control. jsdom reports scrollHeight === clientHeight for every element and never scrolls one, so the geometry belongs to the browser scenario; the wheel-chaining cases stub the scrollport's metrics rather than the textarea's.
composer-draft-scroll.e2e.ts measures the rest in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls. Every metric is read in the caret's own coordinate frame — where the textarea places line n, offset included — against a DOM Range over the backdrop's text for the same line, because that difference is what a user sees. The decisive case changes the offset and re-reads that difference before the task ends, which is before any scroll listener could have run: 0 with one scrollport, and the full delta with a mirror. A vacuity guard asserts the draft overflows the capped box first, and separate cases cover the cap, one wrap width across all three layers, a wheel gesture, a trailing-newline draft, and the caret-reveal path that the textarea's own scrolling used to handle — typing after scrolling away must bring the scrollport back to the caret.
A separate case covers the paste path end to end: a short draft, the caret at its end, and one paste event carrying real clipboard data — the same event a Cmd-V delivers, through the same handler — then the offset and the last pasted line. It waits on the offset rather than on the draft overflowing, because the restore lands one frame after the machine commits the draft; a build without the reveal fails that wait.
The two-geometry comparison behind the decision was measured on a standalone harness before implementing, since the old and new arrangements cannot both exist in the app at once: the same-task separation is 203/202/203px old against 3/2/3px new (chromium/firefox/WebKit), the wrap widths 768-against-776 old on WebKit against 1264/1264/1264 new on all three, and the textarea's own scrollable overflow 0 in the new geometry, which is what makes a second offset impossible rather than merely equal.
Note that the composer ships inside a client-module bundle, so pnpm run build:web alone does not pick up a change to InputBar.tsx — the package build must run for the browser lane to see it, and a scenario run against a stale lib/ asserts against an older client than the tree.
中文
问题
composer 的文本由两层叠放绘制(见 InputBar):<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 软换行恢复会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。
上一版机制所需要的两样东西随它一起消失:
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 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,backdrop 的文本现在就是草稿本身、不多不少,且渲染后才到达的持久化草稿会回视其光标,同时不从其他控件夺走焦点。jsdom 对任何元素都报告 scrollHeight === clientHeight 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。
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/ 跑场景,断言的是比当前代码树更旧的客户端。