fix(web): reveal the caret in the composer's own scrollport on a session switch

preventScroll stopped the browser from revealing a focus nobody gestured for, and took the caret with it. 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, 940px below a box sitting at 0, where the old geometry reached the caret at 628. The unlock effect now suppresses the browser's walk and reveals the caret in its own scrollport, landing at 625 of 628. The focus-keeping mousedown gets preventScroll too and needs no reveal. revealCaret returns early when the box cannot scroll: the draft fits, so every caret is already in view.
This commit is contained in:
creatixchu
2026-07-31 16:38:36 +08:00
parent 1128b7071e
commit 065ed6337e
5 changed files with 95 additions and 28 deletions
@@ -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: 65443bbbf5376727326b9ffe68e16f8b8332f6ae
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 4017b718b3f2d8c023df6ba3ff584a0f33792614
2026-07-31-composer-text-layers-share-one-scrollport.md: 78a02560c13af1746e3cd2a4d595fd510e8b9b1b
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: e3e397a4b4c4893089407891f061e3e01155fcfd
@@ -63,7 +63,7 @@ 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()` 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.
- 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.
- 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 three 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.
@@ -63,7 +63,7 @@ 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`。这是唯一一次「由我们发起而非由手势发起」的回视,抑制它可以避免用户只是切了个会话、transcript 却被挪走
- composer 自己发起`focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦,不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后,光标就回到了我们手上,而这在一条路径上确实要紧:composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视
- 撤销/重做是唯一一条改动草稿却不恢复光标、因而也不回视的路径:状态机重放上一版草稿,DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外三处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。
- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。
@@ -88,12 +88,46 @@ export function InputBar({
const locked = disabled
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// 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.
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
// not act.
//
// 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.
const revealCaret = (caret: number): void => {
const scrollEl = scrollRef.current
const text = mirrorRef.current?.firstChild
if (scrollEl === null || !(text instanceof Text)) return
// A box that cannot scroll has nothing to reveal: the draft fits, so every
// caret is already in view and the assignment below would clamp to itself.
if (scrollEl.scrollHeight <= scrollEl.clientHeight) 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
}
// 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
// would walk up to the conversation scrollport and move the transcript under
// a user who only switched session. That leaves the caret to us — the 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, 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.
useEffect(() => {
if (!locked) inputRef.current?.focus({ preventScroll: true })
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])
// Caret restore after an edit the composer performs itself. The machine owns
@@ -103,25 +137,10 @@ export function InputBar({
// 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
revealCaret(caret)
})
}
@@ -286,10 +305,13 @@ export function InputBar({
void e
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
// Button presses steal focus from the textarea; suppress at mousedown so
// typing continues seamlessly. `preventScroll` for the same reason as the
// unlock effect, and with no reveal of its own: the caret has not moved, and
// the next keystroke gets the browser's native one.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}
const onToggleCommandMenu = (): void => {
@@ -25,6 +25,8 @@ afterEach(cleanup)
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
Range.prototype.getBoundingClientRect = ZERO_RECT
const nativeSetStart = Range.prototype.setStart
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
@@ -323,8 +325,24 @@ describe('running and lock semantics (queue cut 1)', () => {
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
expect(mirror.firstChild).toBeInstanceOf(Text)
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
// jsdom reports scrollHeight === clientHeight for every element, which is
// the composer's own "nothing to reveal" case; a scrollable box is what
// puts the reveal on the table at all.
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
onTestFinished(() => {
Range.prototype.getBoundingClientRect = ZERO_RECT
Range.prototype.setStart = nativeSetStart
})
// Which layer the caret is measured against, and at which index: the stub
// records `setStart` so a helper that measured the backdrop instead, or
// always collapsed at 0, fails here rather than only in the browser lane.
let measured: { node: Node; offset: number } | null = null
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
measured = { node, offset }
nativeSetStart.call(this, node, offset)
}
const caretAt = (top: number): void => {
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
}
@@ -336,6 +354,10 @@ describe('running and lock semantics (queue cut 1)', () => {
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// Measured on the mirror's own text, at the index the paste left the caret
// (an empty draft's selection start, 0, plus the pasted length).
expect(measured!.node).toBe(mirror.firstChild)
expect(measured!.offset).toBe('pasted'.length)
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
@@ -348,6 +370,29 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
})
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
// The composer DOM is reused across sessions, so the previous session's
// offset survives while the value swap puts the caret at the new draft's
// end. `preventScroll` keeps the browser from revealing it through the
// conversation scrollport, which leaves the reveal to the effect itself.
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
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 focused: (boolean | undefined)[] = []
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
expect(focused).toEqual([true])
expect(scroll.scrollTop).toBe(88) // 524 - 436
expect(mirror.firstChild).toBeInstanceOf(Text)
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')