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 a4838efc11..d4758aa0d2 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: 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 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 65443bbbf5..78a02560c1 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 @@ -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. 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 4017b718b3..e3e397a4b4 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 @@ -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 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 1283083d05..03d329a1db 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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): void => { e.preventDefault() - inputRef.current?.focus() + inputRef.current?.focus({ preventScroll: true }) } const onToggleCommandMenu = (): void => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 715ae341df..a55d32d9a2 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -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('[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('[data-input-scroll]')! + const mirror = view.container.querySelector('[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() }) + 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('会话不可用')