diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index 02c04c4293..6b8933a5f8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: 35015161ee1e01129eeb7b29214ac7f8fd4e2e32 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: eccdcf47c36e126954ff37c7a7137f9d422ab3cd +2026-07-31-composer-text-layers-share-one-scrollport.md: ba11384409714d6a64a964d63197705acf39d213 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 9a4a2bcc9f947d385d1ff9fc6367b9a3eab17733 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index 35015161ee..ba11384409 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -32,7 +32,7 @@ Two things the previous mechanism needed are gone with it: **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). +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. @@ -65,13 +65,13 @@ Revealing the caret is the one thing that now depends on the browser rather than - 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: contain` and `contain: paint` both 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`/`PageUp` never moved a textarea's caret in the first place — chromium scrolls a page and leaves `selectionStart` where 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 pass `preventScroll` — 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. The effect also runs when a draft the user did not type arrives: `ConversationSession` seeds 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. -- Undo and redo are the one draft-changing path with no caret restore, and so no 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. +- The composer's own `focus()` calls pass `preventScroll` — 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: `ConversationSession` seeds 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](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) asserts what jsdom can see: that one scrolling box contains both the textarea and the backdrop, and that the backdrop's text is now the draft and nothing else. 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. +The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/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](../../../../apps/web/tests/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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index eccdcf47c3..9a4a2bcc9f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -32,7 +32,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl **由 composer 自己完成的编辑,现在会主动请求回视。** 粘贴与剪切都会抑制原生编辑——草稿与撤销日志归状态机所有——再用 `setSelectionRange` 恢复光标,而这不会带来任何回视:在 chromium 与 WebKit 上实测,粘贴一大段之后视图停在原处,光标却落在所粘内容的末尾。该缺陷早于本次改动(Firefox 只在旧几何下恰好会回视),在此修复,是因为单一滚动容器才终于让「回视」成为我们能自己做的事。两处恢复共用一个 helper:它以隐藏的镜像层为标尺——同一份草稿、同一套度量、同一折行宽度,因此在光标索引处折叠一个 Range 就能报出光标位置,无需任何 caret API——并且只滚动到刚好把该行带进可见范围为止,与浏览器为输入所做的一致。 -有一种形状需要单独的规则,因为引擎之间在这里并不一致:紧跟在换行之后的光标,落在一条没有任何内容可供度量的行上——以换行结尾的草稿正是终止于此。chromium 对这个折叠位置**根本不返回任何 client rect**(一个全零盒子,会把回视带向反方向),firefox 报的是上一行,WebKit 报的才是对的那一行。因此该 helper 改为度量光标刚离开的那个换行——它的盒子就是光标来的那一行——再往下走一行;三者随即落在同一个偏移上(649/652,光标所在行位于 336px 盒内的 315)。 +有一种形状需要单独的规则,因为引擎之间在这里并不一致:紧跟在换行之后的光标,落在一条没有任何内容可供度量的行上——以换行结尾的草稿正是终止于此。chromium 对这个折叠位置**根本不返回任何 client rect**(一个全零盒子,会把回视带向反方向),firefox 报的是上一行,WebKit 报的才是对的那一行。因此该 helper 改为度量光标刚离开的那个换行——它的盒子就是光标来的那一行——再往下走一行;三者随即落在同一个偏移上(649/652,光标所在行位于 336px 盒内的 315)。即使草稿以连续换行结尾,覆盖该换行的非折叠 Range 在三个引擎上都会返回真实矩形,因此每个尾随空行都能沿用同一条规则定位。 现在唯一依赖浏览器而非依赖我们自己的,是把光标滚入可见范围:textarea 没有了自己的偏移,它的 scroll-into-view 必须向上走到滚动容器。实测的每个引擎都会这么做——在草稿末尾输入会把滚动容器带到光标处(chromium、firefox、WebKit 分别为 625、626、628,最大值 628),用 `ArrowUp` 把光标一路走回去会滚回去,滚离光标后再输入也会回到光标。 @@ -65,13 +65,13 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl - chip、claim token 高亮与文本引用标记在滚动时仍与其字形对齐,因为它们定位在 backdrop 内部、随之移动。除去掉哨兵之外,装饰扫描没有变化。 - 在 Firefox 与 WebKit 上,点进一个草稿超过上限的 composer 现在还会把会话记录滚动到底部:光标的 scroll-into-view 会越过 composer 的滚动容器一路走到会话记录的滚动容器,而一个比自身盒子矮的 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 里注入持久化草稿,而父组件的 mount effect 晚于子组件,否则第一次回视量到的是空镜像,且不会再为随后出现的那份草稿重跑。 -- 撤销/重做是唯一一条改动草稿却不恢复光标、因而也不回视的路径:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外两处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。 +- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。另一个只负责回视的 effect 会处理渲染后才到达的非空草稿:`ConversationSession` 在自己的 mount effect 中注入持久化草稿,而该 effect 在本组件的 effect 之后运行,否则第一次回视会量到空镜像,且不会再为随后出现的草稿重跑。第二个 effect 从不聚焦,因此发送后清空或发送失败后恢复这类普通的空/非空转换不会从其他控件夺走焦点。 +- 撤销/重做可以在不恢复光标、也不回视的情况下改动草稿:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外两处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。 - 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。 ## 测试 -[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) 中的单元用例断言 jsdom 能看见的部分:同一个滚动盒同时包含 textarea 与 backdrop,且 backdrop 的文本现在就是草稿本身、不多不少。jsdom 对任何元素都报告 `scrollHeight === clientHeight` 且从不滚动,因此几何属于浏览器场景;滚轮接力用例改为桩接滚动容器的度量,而非 textarea 的。 +[input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.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 自身滚动承担的光标回视路径——滚离光标后输入,必须把滚动容器带回光标处。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 620c631c68..72c7638c89 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -124,6 +124,16 @@ export function InputBar({ else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line } + // Reveal the focus end of the current selection. Today's entry paths leave a + // collapsed selection, but honoring direction keeps a future range-preserving + // path from revealing its anchor instead of its focus. + const revealSelectionFocus = (el: HTMLTextAreaElement): void => { + // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. + const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd + // oxlint-disable-next-line typescript/no-unnecessary-condition + revealCaret(caret ?? el.value.length) + } + // Unlock (mount / session switch) returns focus to the box, and owns the // reveal that comes with it. `preventScroll` because this focus is ours, not // a gesture: the textarea is as tall as the draft, so the browser's reveal @@ -133,22 +143,24 @@ export function InputBar({ // offset while the value swap puts the caret at the new draft's end, which is // off screen (measured on all three engines: offset 0 with the caret 940px // down). Suppress the walk, then reveal in our own box. - // - // `draft !== ''` is the third dependency because a persisted draft arrives - // AFTER this effect: ConversationSession adopts it in its own mount effect, - // and a parent's mount effect runs after its children's. Without that - // dependency the reveal would measure an empty mirror and never run again for - // the draft that then appeared, leaving a restored long draft showing its head - // with the caret at its end. Clearing on send and typing the first character - // flip it too, where both the focus and the reveal are no-ops. useEffect(() => { const el = inputRef.current if (locked || el === null) return el.focus({ preventScroll: true }) - // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. - // oxlint-disable-next-line typescript/no-unnecessary-condition - revealCaret(el.selectionStart ?? el.value.length) - }, [locked, sessionId, draft !== '']) + revealSelectionFocus(el) + }, [locked, sessionId]) + + // A persisted draft arrives AFTER the unlock effect: ConversationSession + // adopts it in its own mount effect, and a parent's mount effect runs after + // its children's. Reveal when the draft becomes non-empty so a restored long + // draft does not stay at its head with the caret at its end. This effect does + // not focus: send-clear, failed-send restore, and first-character transitions + // must not steal focus from another control the user moved to. + useEffect(() => { + const el = inputRef.current + if (locked || draft === '' || el === null) return + revealSelectionFocus(el) + }, [draft !== '']) // Caret restore after an edit the composer performs itself. The machine owns // the draft and the undo log, so paste and cut suppress the native edit and diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index b18d2b0bf1..09d3ddd08a 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -506,7 +506,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('a persisted draft adopted after mount gets its caret revealed too', () => { // ConversationSession seeds the stored draft in its own mount effect, which // runs after this component's: the first reveal measures an empty mirror, - // so the draft's arrival has to run it again. + // so the draft's arrival has to run it again without reclaiming focus. const { view, textarea, shell } = bench() const scroll = view.container.querySelector('[data-input-scroll]')! const mirror = view.container.querySelector('[data-input-mirror]')! @@ -519,8 +519,13 @@ describe('running and lock semantics (queue cut 1)', () => { Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true }) Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true }) Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect + const other = document.createElement('input') + document.body.appendChild(other) + onTestFinished(() => { other.remove() }) + other.focus() expect(scroll.scrollTop).toBe(0) act(() => { shell.setDraft('restored\n'.repeat(40)) }) + expect(document.activeElement).toBe(other) // The caret the machine left at the draft's end, revealed once the draft exists. expect(textarea.selectionStart).toBe(textarea.value.length) expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436