From f8ef2cf36b5e9d24de7d8227bcb0f085be31021d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 12:12:54 +0800 Subject: [PATCH] fix(web): give the backdrop the trailing-line sentinel so the layers share one extent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a real divergence the earlier measurements missed: mirroring an offset is only correct while both layers can reach it, and for a draft ending in a newline the backdrop could not. A textarea reserves a line box for the caret after a final newline. `white-space: pre-wrap` collapses a text node's trailing newline and generates none. So a draft ending in a newline made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — and the mirrored assignment clamped, leaving the glyphs one line behind the caret at the very bottom of the draft. The backdrop now carries the same trailing-line sentinel the mirror div has carried all along: its content is the decoration walk plus one newline. The same pre-wrap collapse absorbs it when the draft does not end in a newline, so it costs no height in the ordinary case, and it supplies the missing line box when it does. Verified in isolation first: a bare pre-wrap div measures 180/180/198 against a textarea's 180/198/216 for zero, one and two trailing newlines, and 180/198/216 with the sentinel. Coverage for the shape that exposed it: the browser scenario asserts the two extents are equal before asserting the glyphs reach the end, observing each layer's maximum by asking for an impossible offset and reading back the clamp rather than computing it from scrollHeight, and the golden records the relation. The unit spec pins the backdrop's text as the draft plus exactly one newline. Removing the sentinel fails both, the e2e with the same 628 against 652. The scrollbar-gutter half of the same review point does not reproduce here: both layers measure clientWidth 776 against a border box of 776 while the draft overflows, so this engine's textarea scrollbar is an overlay and takes no width out of the wrap. --- ...-glyph-layer-tracks-the-textarea.i18n.yaml | 4 +- ...omposer-glyph-layer-tracks-the-textarea.md | 11 ++- ...oser-glyph-layer-tracks-the-textarea.zh.md | 11 ++- apps/web/tests/composer-draft-scroll.e2e.ts | 70 ++++++++++++++++++- .../geometry.expected.md | 7 ++ .../src/client/skeleton/InputBar.tsx | 9 +++ .../ui-conversation/tests/input-bar.spec.tsx | 15 ++++ 7 files changed, 117 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml index bd961e16dc..b0106b0de6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.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-glyph-layer-tracks-the-textarea.md -2026-07-31-composer-glyph-layer-tracks-the-textarea.md: ca5bc407f85199970854ec4853acc0843addae79 -2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: b662cb35563770a1cab5df006ccc33a27e2ef4de +2026-07-31-composer-glyph-layer-tracks-the-textarea.md: bcbf88be58dd57ab7643f4c16886874355d2d9d4 +2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: 7547e0de310f83dc4ccdda122ae435e923895a25 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md index ca5bc407f8..bcbf88be58 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md @@ -18,7 +18,9 @@ The defect is therefore exactly as old as the cap, and it hid behind the resting `InputBar` mirrors the textarea's `scrollTop` onto the backdrop from one `scroll` listener, registered beside the existing wheel-chaining listener in the same effect (the textarea is never unmounted — the inert state renders the same element disabled). -One listener is the whole coupling, because every way the box moves ends in a `scroll` event on the textarea. A gesture scrolls it; an edit scrolls the caret into view; a draft that shrinks past the current offset clamps it. The clamp case is the one that looks like it needs separate handling and does not: the two layers share an extent — measured equal in chromium for plain, soft-wrapped, and unbreakable-run drafts — so they clamp to the same maximum, and the textarea's clamp fires the `scroll` that mirrors it. +One listener is the whole coupling, because every way the box moves ends in a `scroll` event on the textarea. A gesture scrolls it; an edit scrolls the caret into view; a draft that shrinks past the current offset clamps it. The clamp case is the one that looks like it needs separate handling and does not: the two layers share an extent, so they clamp to the same maximum, and the textarea's clamp fires the `scroll` that mirrors it. + +That shared extent is not free, and mirroring an offset is only correct while it holds. A textarea reserves a line box for the caret after a final newline; `white-space: pre-wrap` collapses a text node's trailing newline and generates none. A draft ending in a newline therefore made the backdrop exactly one line shorter than the textarea — measured 628 against 652 — so the assignment clamped and the glyphs sat a line behind the caret at the very bottom. The backdrop now carries the same trailing-line sentinel the mirror div already did: its content is the decoration walk plus one `'\n'`, which the same collapse absorbs when the draft does not end in a newline and which supplies the missing line box when it does. Measured across plain, trailing-newline, soft-wrapping, unbreakable-run, and interior-blank-line drafts, the two extents now agree in every case. The mirror is one-directional: the textarea is the authority because it owns the caret, and the caret is what the browser scrolls to. @@ -32,6 +34,8 @@ The mirror is one-directional: the textarea is the authority because it owns the **Scroll the backdrop from the existing wheel handler instead of a `scroll` listener.** The handler already runs on every wheel over the textarea, so it looks like the natural place. Rejected because it covers only one of the ways the box scrolls: typing at the end, `End`, arrow keys, drag-selection past the edge, and scrollbar drags all move the textarea without a wheel event. Listening to `scroll` is listening to the thing itself rather than to one of its causes. +**Translate the backdrop with `transform: translateY(-scrollTop)` instead of scrolling it.** A transform is not clamped by content height, so it would paper over any extent divergence — including the trailing-newline one — without matching the layers. Rejected because the divergence is the actual defect: unequal extents also mean the two layers disagree about where the last line sits, and hiding that behind an unclamped transform would leave a mismatch that resurfaces the moment anything measures the backdrop. Fixing the extent keeps one truth about the draft's height. + **Add a second mirror in a layout effect keyed on the committed draft.** This shipped in the first version of the change, on the theory that an edit reflows both layers without necessarily moving the textarea, and that a shrinking draft clamps each layer independently. Both premises are false, and it was removed after mutation-testing each hook alone against the built client: with only the layout effect disabled the browser scenario stays green, while disabling only the `scroll` listener fails it. Typing scrolls the caret into view, which is an ordinary `scroll`; a shrinking draft clamps both layers to the same maximum because their extents are equal, and the textarea's clamp fires `scroll` too. The specific hazard the effect was imagined to cover — React replacing the backdrop's children when the decoration set changes shape, resetting its offset — does not occur: measured in chromium, replacing every child of an `overflow: hidden` box preserves `scrollTop` (300 stays 300), and the only replacement that zeroes it is one that shrinks the content below the offset, which is the clamp case already covered. **Sync in the `onChange` handler.** Rejected for the same reason plus one of its own: it fires before React commits the new draft to the backdrop, so it would mirror against the previous layout. @@ -41,13 +45,14 @@ The mirror is one-directional: the textarea is the authority because it owns the - A draft past the cap scrolls its glyphs. Measured in the browser scenario: after a wheel gesture over a 40-line draft the last line sits inside the visible box and the first has scrolled out above it; before, the last line stayed a full draft-height below the box while the textarea's own offset had moved. - The coupling is one-directional and cheap — one assignment of one number, no measurement, no layout read beyond `scrollTop` — so it adds nothing to the typing path's cost. - 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. Nothing about the decoration walk changes. -- The composer's two-layer design keeps this hazard: any future layer added beside the backdrop needs the same mirroring. The e2e scenario asserts the relation the user cares about (which line is on screen) rather than the mechanism, so it holds whatever the layer count becomes. +- The composer's two-layer design keeps this hazard: any future layer added beside the backdrop needs the same mirroring, and any change to how a layer reserves its last line box breaks the extent equality the mirror depends on. The e2e scenario asserts both — the relation the user cares about (which line is on screen) and the extent equality underneath it — so a future divergence fails on the invariant rather than on a screenshot. +- Extent equality is asserted, not assumed. It is the one premise that turns "mirror the offset" from correct into subtly wrong, and it failed for the trailing-newline shape before the sentinel. ## Testing The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversation/tests/input-bar.spec.tsx) proves the mirroring path runs: it stubs both offsets, because jsdom reports `scrollHeight === clientHeight` for every element and never scrolls one, and asserts the backdrop follows the textarea to a new offset and back to the top. Reverting the `ref` makes it fail. -The user-visible fact needs a real engine, so [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures it in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls, with a DOM Range over the backdrop's own text reporting where the first and last lines sit relative to the visible box. A vacuity guard asserts the draft actually overflows the capped box first. +The user-visible fact needs a real engine, so [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) measures it in chromium against the built client: a 40-line draft in a fresh workspace's blank composer, zero model calls, with a DOM Range over the backdrop's own text reporting where the first and last lines sit relative to the visible box. A vacuity guard asserts the draft actually overflows the capped box first. A separate case drives the trailing-newline shape and asserts the two extents are equal before asserting the glyphs reach the end; each layer's maximum is observed by asking for an impossible offset and reading back the clamp, not computed from `scrollHeight`. Confirmed both directions against the built client. With the mirroring reverted and the packages rebuilt, the wheel case fails on the layer offsets, the typing case fails with it, and the golden diff reads `last draft line is on screen: false` while `textarea moved: true` — the reported symptom stated as a fixture. The resting-state case passes in both builds, which is the point: it is the state that hid the defect. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md index b662cb3556..7547e0de31 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md @@ -18,7 +18,9 @@ Status: implemented `InputBar` 通过一个 `scroll` 监听把 textarea 的 `scrollTop` 镜像到 backdrop 上,该监听与既有的滚轮接力监听注册在同一个 effect 中(textarea 从不卸载——失效状态渲染的是同一个元素的 disabled 形态)。 -一个监听即构成完整耦合,因为这个盒子移动的每一种方式最终都会在 textarea 上产生 `scroll` 事件:手势使它滚动;编辑会把光标滚入可见范围;草稿缩短到当前偏移之下时它会被钳位。看似需要单独处理、实则不需要的正是钳位这一种:两层共享同一滚动范围——在 chromium 中对纯文本、软折行与不可断长串三类草稿实测均相等——因此它们会钳位到同一个最大值,而 textarea 的钳位本身就会触发那次完成镜像的 `scroll`。 +一个监听即构成完整耦合,因为这个盒子移动的每一种方式最终都会在 textarea 上产生 `scroll` 事件:手势使它滚动;编辑会把光标滚入可见范围;草稿缩短到当前偏移之下时它会被钳位。看似需要单独处理、实则不需要的正是钳位这一种:两层共享同一滚动范围,因此它们会钳位到同一个最大值,而 textarea 的钳位本身就会触发那次完成镜像的 `scroll`。 + +这个「共享的滚动范围」并非白得,而镜像偏移只有在它成立时才是正确的。textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行、不生成任何行盒。因此以换行结尾的草稿会让 backdrop 恰好比 textarea 少一行——实测为 628 对 652——于是该赋值被钳制,滚到最底部时字形比光标落后一行。现在 backdrop 也带上了镜像层早已具备的同一枚尾行哨兵:其内容为装饰扫描的结果再加一个 `'\n'`;草稿不以换行结尾时它被同一次折叠吸收,以换行结尾时它补上缺失的那个行盒。对纯文本、尾随换行、软折行、不可断长串以及中间空行五类草稿实测,两侧范围在每种情形下均相等。 该镜像是单向的:textarea 是权威方,因为它持有光标,而浏览器滚动的目标正是光标。 @@ -32,6 +34,8 @@ Status: implemented **在既有的滚轮处理函数里滚动 backdrop,而不是新增 `scroll` 监听。** 该处理函数本就在 textarea 上的每次滚轮时运行,看似是自然的落点。之所以否决,是它只覆盖了盒子滚动的其中一种成因:在末尾输入、`End`、方向键、拖选越过边缘、拖动滚动条,都会在没有滚轮事件的情况下移动 textarea。监听 `scroll` 是在监听事情本身,而不是它的某一个成因。 +**改用 `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 之前触发,因而会按上一次的布局做镜像。 @@ -41,13 +45,14 @@ Status: implemented - 超过上限的草稿会滚动其字形。浏览器场景实测:在 40 行草稿上做一次滚轮手势后,最后一行位于可见盒子之内,第一行已滚出上方;此前最后一行仍停在盒子下方整整一个草稿高度处,而 textarea 自身的偏移已经移动了。 - 该耦合是单向且廉价的——一次对一个数字的赋值,没有测量,除 `scrollTop` 外没有额外的布局读取——因此不会给输入路径增加开销。 - chip、claim token 高亮与文本引用标记在滚动时始终与其字形对齐,因为它们定位在 backdrop 内部并随之移动。装饰扫描本身没有任何改动。 -- composer 的双层设计保留了这一隐患:日后在 backdrop 旁新增的任何一层都需要同样的镜像。e2e 场景断言的是用户真正关心的关系(哪一行在屏幕上),而非实现机制,因此无论层数变成多少它都成立。 +- composer 的双层设计保留了这一隐患:日后在 backdrop 旁新增的任何一层都需要同样的镜像;而任何改变某一层如何保留其末行行盒的改动,都会破坏镜像所依赖的范围相等性。e2e 场景对两者都做了断言——用户真正关心的关系(哪一行在屏幕上),以及其下的范围相等性——因此日后一旦出现偏差,失败会落在不变量上,而不是落在某张截图上。 +- 范围相等性是被断言的,而非被假定的。它正是那个能把「镜像偏移」从正确变为微妙错误的唯一前提,并且在加入哨兵之前,它在尾随换行这一形态上确实不成立。 ## 验证 [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 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。 +用户可见的事实需要真实引擎,因此 [composer-draft-scroll.e2e.ts](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) 在 chromium 中针对构建产物客户端测量它:在全新工作区空白会话的 composer 中放入 40 行草稿,零模型调用,用一个跨越 backdrop 自身文本的 DOM Range 报告首行与末行相对于可见盒子的位置。一个防空转守卫会先断言草稿确实溢出了设有上限的盒子。另有一个独立用例驱动尾随换行这一形态,先断言两侧范围相等,再断言字形确实抵达末尾;每一层的最大值都通过请求一个不可能的偏移再读回其钳位结果来观测,而非由 `scrollHeight` 计算得出。 已双向确认。撤掉镜像并重新构建各包后,滚轮用例在两层偏移上失败,输入用例随之失败,golden 差异读作 `last draft line is on screen: false` 而 `textarea moved: true`——即以 fixture(测试前置数据)形式陈述的原始现象。静止状态用例在两种构建下都通过,这正是要点所在:它就是掩盖了该缺陷的那个状态。 diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index cc3e5b46bb..6cd49c2be5 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -59,6 +59,15 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => { return `draft line ${String(index + 1).padStart(2, '0')}` }).join('\n') +/** + * A draft ending in a newline: the shape whose layer extents diverge without + * the backdrop's trailing-line sentinel. A textarea reserves a line box for the + * caret after a final newline; `white-space: pre-wrap` collapses a text node's + * trailing newline and generates none, so the backdrop would come out exactly + * one line shorter and the mirrored offset would clamp a line above the caret. + */ +const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n` + /** The composer's two text layers as the browser lays them out. */ interface ComposerMetrics { /** True when the draft is taller than the capped box — the situation under test. */ @@ -82,6 +91,10 @@ interface ComposerMetrics { lastLineOffset: number /** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */ firstLineOffset: number + /** Furthest the textarea can scroll. */ + inputMax: number + /** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */ + backdropMax: number } /** @@ -109,7 +122,20 @@ function measureComposer(page: Page): Promise { return range.getBoundingClientRect().top - box.top } const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight) + // Each layer's own maximum, probed by asking for an impossible offset and + // reading back what it clamped to, then restored. Reading scrollHeight - + // clientHeight instead would compute the maximum rather than observe it. + const restore = input.scrollTop + const restoreBackdrop = backdrop.scrollTop + input.scrollTop = 1e7 + backdrop.scrollTop = 1e7 + const inputMax = input.scrollTop + const backdropMax = backdrop.scrollTop + input.scrollTop = restore + backdrop.scrollTop = restoreBackdrop return { + inputMax, + backdropMax, overflows: input.scrollHeight > input.clientHeight, clientHeight: input.clientHeight, visibleLines: Math.floor(input.clientHeight / lineHeight), @@ -135,7 +161,7 @@ function measureComposer(page: Page): Promise { * @param bottom - metrics with the draft scrolled to its end. * @returns the golden body, without a trailing newline. */ -function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics): string { +function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string { return [ '# Composer draft scrolling (14-line cap, two text layers)', '', @@ -143,6 +169,7 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics): string { '', `- draft overflows the capped box: ${String(top.overflows)}`, `- visible lines: ${String(top.visibleLines)}`, + `- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`, `- textarea scroll offset: ${String(top.inputScrollTop)}px`, `- glyph layer tracks it: ${String(top.layersAgree)}`, `- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`, @@ -154,6 +181,12 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics): string { `- glyph layer tracks it: ${String(bottom.layersAgree)}`, `- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`, `- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`, + '', + '## Draft ending in a newline, scrolled to the end', + '', + `- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`, + `- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`, + `- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`, ].join('\n').trimEnd() } @@ -246,6 +279,31 @@ describe('web e2e: composer draft scrolling', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('a draft ending in a newline scrolls to its true end, not a line above it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline')) + // The layers reserve a final line box on different terms, so this shape is + // the one that separates equal extents from a mirror that clamps early. + const input = page.locator('textarea:enabled').first() + await input.fill(DRAFT_TRAILING_NEWLINE) + await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) + const extents = await measureComposer(page) + // The invariant the sentinel exists for. Without it the textarea measured + // 652 against the backdrop's 628 — one 24px line apart. + expect(extents.backdropMax).toBe(extents.inputMax) + await input.hover() + await page.mouse.wheel(0, 4000) + await expect.poll(async () => { + const m = await measureComposer(page) + return m.inputScrollTop === m.inputMax + }, { timeout: 10_000 }).toBe(true) + const bottom = await measureComposer(page) + // At the very bottom the glyphs are level with the caret, not a line behind. + expect(bottom.layersAgree).toBe(true) + expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0) + expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('matches the committed composer scroll geometry golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden')) const input = page.locator('textarea:enabled').first() @@ -261,7 +319,15 @@ describe('web e2e: composer draft scrolling', () => { await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }) .toBeGreaterThan(0) const bottom = await measureComposer(page) - await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom), MODE) + await input.fill(DRAFT_TRAILING_NEWLINE) + await input.hover() + await page.mouse.wheel(0, 4000) + await expect.poll(async () => { + const m = await measureComposer(page) + return m.inputScrollTop === m.inputMax + }, { timeout: 10_000 }).toBe(true) + const trailingNewline = await measureComposer(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md index 6f3e0bd881..bfcb273827 100644 --- a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md @@ -4,6 +4,7 @@ - draft overflows the capped box: true - visible lines: 14 +- both layers share one scroll extent: true - textarea scroll offset: 0px - glyph layer tracks it: true - first draft line is on screen: true @@ -15,3 +16,9 @@ - glyph layer tracks it: true - first draft line has scrolled out above: true - last draft line is on screen: true + +## Draft ending in a newline, scrolled to the end + +- both layers share one scroll extent: true +- glyph layer tracks the caret: true +- last draft line is on screen: true diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 354a4a5f20..70925425dc 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -375,6 +375,15 @@ export function InputBar({ const displayHint = translated !== hintKey ? translated : deco.hint backdrop.push({displayHint}) } + // Trailing-line sentinel, the same one the mirror div carries and for the + // same reason: 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 and generates none. Without it a draft ending in a newline makes + // the backdrop exactly one line SHORTER than the textarea, so mirroring the + // offset at the very bottom clamps and the glyphs sit a line behind the + // caret. The extra newline is absorbed by that same collapse when the draft + // does not end in one, so it costs no height in the ordinary case. + backdrop.push('\n') } return ( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c5ca9fcf9a..8315a8111a 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -304,6 +304,21 @@ describe('running and lock semantics (queue cut 1)', () => { expect(backdrop.scrollTop).toBe(0) }) + it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => { + // jsdom has no layout, so the HEIGHTS this protects cannot be asserted here + // (the browser scenario owns that); what is checkable is that the backdrop's + // text is the draft plus exactly one newline. A textarea reserves a line box + // after a final newline and `pre-wrap` collapses one, so without the + // sentinel a draft ending in a newline leaves the backdrop a line short and + // the mirrored offset clamps. + const withNewline = bench({ draft: 'alpha\nbeta\n' }) + const backdrop = withNewline.view.container.querySelector('[data-input-backdrop]')! + expect(backdrop.textContent).toBe('alpha\nbeta\n\n') + const withoutNewline = bench({ draft: 'alpha\nbeta' }) + const plain = withoutNewline.view.container.querySelector('[data-input-backdrop]')! + expect(plain.textContent).toBe('alpha\nbeta\n') + }) + it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { const { textarea } = bench({ disabled: true }) expect(textarea.placeholder).toBe('会话不可用')