From e465806120fbd4c0ea41d4b02dc9fb2486e9b7c0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:07:55 +0800 Subject: [PATCH] fix(web): scroll to the caret after an edit the composer performs itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a long block left the view where it was while the caret sat at the end of what was pasted. Paste, ctrl/meta-Enter newline and cut all 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, before this branch as well as on it. Firefox happened to reveal it, in the old geometry only. The three restores now 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 scrollport the minimum that brings the line inside, which is what the browser does for typing. One scrollport is what makes this possible at all: the reveal is finally a single offset to move. Also from review: the composer's own focus() on unlock and session switch passes preventScroll, so a session switch cannot move the transcript through the taller textarea's reveal chain. --- ...text-layers-share-one-scrollport.i18n.yaml | 4 +- ...mposer-text-layers-share-one-scrollport.md | 8 ++- ...ser-text-layers-share-one-scrollport.zh.md | 8 ++- apps/web/tests/composer-draft-scroll.e2e.ts | 58 ++++++++++++++++++- .../geometry.expected.md | 6 ++ .../src/client/skeleton/InputBar.tsx | 46 ++++++++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 50 ++++++++++++++-- 7 files changed, 163 insertions(+), 17 deletions(-) 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 56f5dc114f..da310699f7 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: ef182f6a62dabfaf5330916c45baf98d1543e6e5 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: b6f5534300f302ff58022733c3493af5eb53428f +2026-07-31-composer-text-layers-share-one-scrollport.md: 480db5f4b45ab22091bbe2e429cbc867d9a198bc +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 5c3253b9a41fb5586a55b57037002bddb6da01d0 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 ef182f6a62..480db5f4b4 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 @@ -26,10 +26,12 @@ The browser then applies one offset to both layers, in the same frame, on the sa 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 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, ctrl/meta-Enter newline 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 three 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. + 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 @@ -60,6 +62,8 @@ Revealing the caret is the one thing that now depends on the browser rather than - The scrollbar moved from the textarea to the scrollport — the same visual place, one box out. The `.card` l2 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: 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()` on unlock and session switch passes `preventScroll`. It is the one reveal that is ours rather than a gesture's, and suppressing it keeps a session switch from moving the transcript under a user who did not touch it. - 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 @@ -68,6 +72,8 @@ The unit spec in [input-bar.spec.tsx](../../../../packages/client/ui-conversatio [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. +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. 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 b6f5534300..5c3253b9a4 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 @@ -26,10 +26,12 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl 上一版机制所需要的两样东西随它一起消失: -**backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行,把镜像偏移钳制在光标上方一行。改为单一滚动容器后,backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。 +**backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行,把镜像偏移钳制在光标上方一行。改为单一滚动容器后,backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制:正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。 **折行宽度这一前提。** 现在三层都在滚动容器内部解析自身宽度,因此一条占布局宽度的滚动条对它们的代价由结构保证相等。这也就关闭了被取代的那篇笔记记录为「悬置且没有任何属性能修」的分歧:WebKit 会为 `overflow-y: auto` 的 textarea 预留槽位,却不为它旁边 `overflow: hidden` 的层预留,把 textarea 排成 768 对 776——在长草稿上值 2 到 5 个折行,也就是在唯一能观察到它的那个引擎上把字形放到了错误的光标之下。改动后在独立环境实测,Playwright 自带的三个引擎上三层宽度均一致。 +**由 composer 自己完成的编辑,现在会主动请求回视。** 粘贴、ctrl/meta-Enter 换行与剪切都会抑制原生编辑——草稿与撤销日志归状态机所有——再用 `setSelectionRange` 恢复光标,而这不会带来任何回视:在 chromium 与 WebKit 上实测,粘贴一大段之后视图停在原处,光标却落在所粘内容的末尾。该缺陷早于本次改动(Firefox 只在旧几何下恰好会回视),在此修复,是因为单一滚动容器才终于让「回视」成为我们能自己做的事。三处恢复共用一个 helper:它以隐藏的镜像层为标尺——同一份草稿、同一套度量、同一折行宽度,因此在光标索引处折叠一个 Range 就能报出光标位置,无需任何 caret API——并且只滚动到刚好把该行带进可见范围为止,与浏览器为输入所做的一致。 + 现在唯一依赖浏览器而非依赖我们自己的,是把光标滚入可见范围:textarea 没有了自己的偏移,它的 scroll-into-view 必须向上走到滚动容器。实测的每个引擎都会这么做——在草稿末尾输入会把滚动容器带到光标处(chromium、firefox、WebKit 分别为 625、626、628,最大值 628),用 `ArrowUp` 把光标一路走回去会滚回去,滚离光标后再输入也会回到光标。 ## 备选方案 @@ -60,6 +62,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl - 滚动条从 textarea 移到了滚动容器上——视觉位置相同,只是外移了一层。`.card` 的 l2 token 绑定仍会继承下去。 - 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`。这是唯一一次「由我们发起而非由手势发起」的回视,抑制它可以避免用户只是切了个会话、transcript 却被挪走。 - 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。 ## 测试 @@ -68,6 +72,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl [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/` 跑场景,断言的是比当前代码树更旧的客户端。 diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index f9fa6dd81d..3dd341e47e 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -198,9 +198,12 @@ function measureComposer(page: Page): Promise { * @param top - metrics with the draft scrolled to its start. * @param bottom - metrics with the draft scrolled to its end. * @param trailingNewline - metrics with the trailing-newline draft scrolled to its end. + * @param pasted - metrics right after a long block was pasted at the draft's end. * @returns the golden body, without a trailing newline. */ -function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string { +function renderGeometry( + top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics, +): string { return [ '# Composer draft scrolling (14-line cap, two text layers, one scrollport)', '', @@ -231,6 +234,14 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingN `- the draft's own last line is on screen: ${String( trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight, )}`, + '', + '## Right after pasting a long block at the end', + '', + `- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`, + `- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`, + `- the pasted block's last line is on screen: ${String( + pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight, + )}`, ].join('\n').trimEnd() } @@ -353,6 +364,37 @@ describe('web e2e: composer draft scrolling', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('pasting a long block scrolls to the caret it leaves at the end', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste')) + // The composer suppresses the native paste — the machine owns the draft and + // the undo log — and restores the caret programmatically, which reveals + // nothing on its own: measured in chromium and WebKit, the view stayed + // where it was while the caret sat at the end of the pasted block. The + // restore now scrolls it into view, and this is the case that proves it. + const input = page.locator('textarea:enabled').first() + await input.fill('one short line') + await input.press('End') + // A real `paste` event carrying real clipboard data, dispatched at the + // textarea: the same event a Cmd-V delivers, and it runs the same handler. + await input.evaluate((el, text) => { + const data = new DataTransfer() + data.setData('text/plain', text) + el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) + }, `\n${DRAFT}`) + await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) + // The restore lands one frame after the machine commits the draft, so the + // box overflows before it moves; waiting on the offset is waiting for the + // behavior itself, and its absence fails this poll. + await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) + const metrics = await measureComposer(page) + // The caret is at the end of what was pasted, so the draft's last line is + // what has to be on screen. + expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0) + expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight) + expect(metrics.gapShiftOnScroll).toBe(0) + 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 @@ -399,7 +441,19 @@ describe('web e2e: composer draft scrolling', () => { return m.scrollTop === m.scrollMax }, { timeout: 10_000 }).toBe(true) const trailingNewline = await measureComposer(page) - await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE) + // The paste path, measured the way a user meets it: a short draft, the + // caret at its end, one long block pasted in. + await input.fill('one short line') + await input.press('End') + await input.evaluate((el, text) => { + const data = new DataTransfer() + data.setData('text/plain', text) + el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) + }, `\n${DRAFT}`) + await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) + await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) + const pasted = await measureComposer(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), 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 51191565df..ef08cdfd94 100644 --- a/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-draft-scroll/geometry.expected.md @@ -23,3 +23,9 @@ - caret sits on its own glyphs: true - the draft's own last line is on screen: true + +## Right after pasting a long block at the end + +- the composer scrolled to the caret it left: true +- caret and glyphs stay level when the offset changes: true +- the pasted block's last 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 3610b9143c..1283083d05 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -63,6 +63,7 @@ export function InputBar({ const empty = draft.trim() === '' const inputRef = useRef(null) const scrollRef = useRef(null) + const mirrorRef = useRef(null) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. const composingRef = useRef(false) @@ -87,11 +88,43 @@ export function InputBar({ const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - // Unlock (mount / session switch) returns focus to the box. + // Unlock (mount / session switch) returns focus to the box. `preventScroll` + // because this focus is ours, not a gesture: the textarea is as tall as the + // draft, so an unsuppressed reveal would walk up to the conversation + // scrollport and move the transcript under a user who only switched session. useEffect(() => { - if (!locked) inputRef.current?.focus() + if (!locked) inputRef.current?.focus({ preventScroll: true }) }, [locked, sessionId]) + // Caret restore after an edit the composer performs itself. The machine owns + // the draft and the undo log, so paste, ctrl/meta-Enter newline and cut all + // suppress the native edit and write the value through the machine — and a + // programmatic selection change 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 the draft. Native typing gets its reveal from the + // browser; these three have to ask for it, so they share one restore. + // + // The mirror is the caret's ruler: it renders the same draft at the same + // metrics and the same wrap width in the same stack (that is what makes it + // the height authority), so a Range collapsed at the caret's index reports + // where the caret is without a caret API. Minimal scroll, matching what the + // browser does for typing: move only far enough to bring the line inside. + const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => { + requestAnimationFrame(() => { + el.setSelectionRange(caret, caret) + const scrollEl = scrollRef.current + const text = mirrorRef.current?.firstChild + if (scrollEl === null || !(text instanceof Text)) return + const range = document.createRange() + range.setStart(text, Math.min(caret, text.data.length)) + range.collapse(true) + const at = range.getBoundingClientRect() + const box = scrollEl.getBoundingClientRect() + if (at.bottom > box.bottom) scrollEl.scrollTop += at.bottom - box.bottom + else if (at.top < box.top) scrollEl.scrollTop -= box.top - at.top + }) + } + // Wheel chaining on the draft scrollport, one lifetime (it is never // unmounted — the inert state renders the same element disabled). While the // capped box can still move in this direction, keep the native scroll; only @@ -167,8 +200,7 @@ export function InputBar({ const el = e.currentTarget const sel = selectionOf(el) keyboard.newline(sel) - const caret = sel.start + 1 - requestAnimationFrame(() => { el.setSelectionRange(caret, caret) }) + restoreCaret(el, sel.start + 1) } return } @@ -224,7 +256,7 @@ export function InputBar({ e.clipboardData.setData('text/plain', text) if (cut && !machineBusy && !locked) { keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - requestAnimationFrame(() => { el.setSelectionRange(start, start) }) + restoreCaret(el, start) } void slice } @@ -243,7 +275,7 @@ export function InputBar({ // land (paste-upgrade). The DOM layer only starts the transaction. keyboard.pasteBegin(text, sel) const caret = sel.start + text.length - requestAnimationFrame(() => { el.setSelectionRange(caret, caret) }) + restoreCaret(el, caret) keyboard.track(keyboard.snapshot.draft, caret) } @@ -404,7 +436,7 @@ export function InputBar({ onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} /> -
{`${draft}\n`}
+
{`${draft}\n`}
diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 876628d4c7..715ae341df 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -4,7 +4,7 @@ // semantics (input stays free; primary turns stop), the machine pending lock, // decoration backdrop, error/notice strips, and the focus-keeping mousedown. -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,6 +18,13 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) +// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect` +// is absent — and the composer measures the caret with one when it restores the +// selection after an edit it performed itself. Every case here runs against a +// zero rect; the reveal case below substitutes its own and restores this one. +const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect +Range.prototype.getBoundingClientRect = ZERO_RECT + const SCTX = {} as ClientContext const SID = 's1' as SessionId @@ -296,9 +303,9 @@ describe('running and lock semantics (queue cut 1)', () => { const backdrop = view.container.querySelector('[data-input-backdrop]')! // The caret is the textarea's and every visible glyph is the backdrop's, so // one box has to carry both or an offset can exist in one and not the other. - // jsdom has no layout — the browser scenario owns the geometry; what is - // checkable here is that there is exactly one scrolling box and it holds - // both layers. + // jsdom has no layout and loads no stylesheet — which box scrolls is the + // browser scenario's to assert; what is checkable here is that the + // scrollport element holds both layers. expect(scroll.contains(textarea)).toBe(true) expect(scroll.contains(backdrop)).toBe(true) // The glyph layer carries the draft and nothing else: with one scrollport @@ -306,6 +313,41 @@ describe('running and lock semantics (queue cut 1)', () => { expect(backdrop.textContent).toBe('line\n'.repeat(40)) }) + it('an edit the composer performs itself scrolls the caret back into view', async () => { + // Paste, ctrl-Enter newline and cut suppress the native edit, so no engine + // reveals the caret for them. jsdom has no layout: the rects are stubbed, + // and what is asserted is the arithmetic — minimal scroll, in both + // directions, and nothing at all for a caret already inside the box. + const { view, textarea } = bench({ draft: 'line\n'.repeat(40) }) + const scroll = view.container.querySelector('[data-input-scroll]')! + const mirror = view.container.querySelector('[data-input-mirror]')! + expect(mirror.firstChild).toBeInstanceOf(Text) + scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect + Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true }) + onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT }) + const caretAt = (top: number): void => { + Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect + } + const settle = async (): Promise => { + await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) }) + } + // Pasted text lands below the fold: scroll down by exactly the overshoot. + caretAt(500) + fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } }) + await settle() + expect(scroll.scrollTop).toBe(88) // 524 - 436 + // A caret already inside the box does not move it. + caretAt(200) + fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } }) + await settle() + expect(scroll.scrollTop).toBe(88) + // Above the fold (a cut can leave it there): scroll back up. + caretAt(60) + fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } }) + await settle() + expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60) + }) + it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { const { textarea } = bench({ disabled: true }) expect(textarea.placeholder).toBe('会话不可用')