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

View File

@@ -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 => {

View File

@@ -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('会话不可用')