fix(web): reveal the caret when a persisted draft arrives after mount

ConversationSession seeds a stored draft in its own mount effect, and a parent's mount effect runs after its children's — so the unlock effect measured an empty mirror and never ran again for the draft that then appeared, leaving a restored long draft showing its head with the caret at its end. The effect now depends on the draft being non-empty; clearing on send and typing the first character flip it too, where both the focus and the reveal are no-ops. Pre-existing (the old geometry did not scroll for a programmatic value change either), fixed here because the reveal now exists.

Also from review: the golden's paste goes back to a block NOT ending in a newline, so the collapsed branch keeps a real engine under it while the standalone case owns the after-newline branch; the shared line-height rule names the reveal as its third consumer.
This commit is contained in:
creatixchu
2026-07-31 17:26:29 +08:00
parent 8b5c3946da
commit c029f03516
7 changed files with 45 additions and 7 deletions

View File

@@ -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: 8c3a8002098177b729da7953210aed0b421729f7
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: b6f9d59516e864053ad7c64cf2c7f1a806044053
2026-07-31-composer-text-layers-share-one-scrollport.md: f4ba206bfb0c3b798e8ad37690d804d67ff9af3c
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 0faca3fb2cab9d4e0a9c3673983014f99edc19d8

View File

@@ -65,7 +65,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()` 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.
- 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. The effect also runs when a draft the user did not type arrives: `ConversationSession` seeds a persisted draft in its own mount effect, which runs AFTER this component's, so the first reveal would otherwise measure an empty mirror and never run again for the draft that then appeared.
- 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.

View File

@@ -65,7 +65,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`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后光标就回到了我们手上而这在一条路径上确实要紧composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。
- composer 自己发起的 `focus()` 全部加了 `preventScroll`——解锁/切会话的 effect,以及工具栏按钮上那个保持焦点的 mousedown——因此一次没有任何手势要求的聚焦不会再通过更高的 textarea 的回视链把 transcript 挪走。抑制这条链之后光标就回到了我们手上而这在一条路径上确实要紧composer 的 DOM 跨会话复用,因此切到更长的草稿时旧偏移会留着,而换值会把光标放到新草稿的末尾。三引擎实测,这会让光标落在停在 0 的盒子下方 940px 处;于是该 effect 会在自己的滚动容器里把它带回来,落点 625/628——正是旧几何靠浏览器达到的 628。mousedown 那条不需要回视:光标没动过,而下一次敲键会拿到浏览器原生的回视。该 effect 在「用户没有敲过的草稿到达时」同样会跑:`ConversationSession` 在它自己的 mount effect 里注入持久化草稿,而父组件的 mount effect 晚于子组件,否则第一次回视量到的是空镜像,且不会再为随后出现的那份草稿重跑。
- 撤销/重做是唯一一条改动草稿却不恢复光标、因而也不回视的路径状态机重放上一版草稿DOM 选区停在浏览器钳位后的位置。这早于本次改动且未被改动——在此点名,是因为另外三处恢复都会回视,会让这处遗漏看起来像有意为之;真被报告时 helper 就在旁边。
- 任何新增在 backdrop 旁边的层都属于滚动容器**内部**,并且必须与草稿等高,否则就会重新引入这一缺陷。这是 composer 长期存在的风险点:两层拆分对 chip 与高亮是承重的,因此耦合必须来自结构,而不是靠维护。

View File

@@ -452,7 +452,10 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
}, `\n${DRAFT}\n`)
// The ordinary shape — not ending in a newline — so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
}, `\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)

View File

@@ -206,6 +206,10 @@
share the stack, so placeholder advances agree by construction. */
font-family: 'DshChipCell', var(--dsw-font-family);
font-size: inherit;
/* Three consumers, not two: the mirror sizes the stack, the layers must break
lines identically, and the caret reveal parses this value to step one line
down for a caret that sits after a newline. That parse needs a length, so a
theme resolving this to `normal` would make the reveal a silent no-op. */
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;

View File

@@ -132,6 +132,14 @@ export function InputBar({
// 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.
//
// `draft !== ''` is the third dependency because a persisted draft arrives
// AFTER this effect: ConversationSession adopts it in its own mount effect,
// and a parent's mount effect runs after its children's. Without that
// dependency the reveal would measure an empty mirror and never run again for
// the draft that then appeared, leaving a restored long draft showing its head
// with the caret at its end. Clearing on send and typing the first character
// flip it too, where both the focus and the reveal are no-ops.
useEffect(() => {
const el = inputRef.current
if (locked || el === null) return
@@ -139,7 +147,7 @@ export function InputBar({
// 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])
}, [locked, sessionId, draft !== ''])
// 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

View File

@@ -378,7 +378,7 @@ describe('running and lock semantics (queue cut 1)', () => {
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
await settle()
// The three pastes accumulate at the draft's head, so the caret is at the
// The four pastes accumulate at the draft's head, so the caret is at the
// end of what they inserted — and the measured index is the newline before it.
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
@@ -421,6 +421,29 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(measured!.offset).toBe(textarea.value.length - 1)
})
it('a persisted draft adopted after mount gets its caret revealed too', () => {
// ConversationSession seeds the stored draft in its own mount effect, which
// runs after this component's: the first reveal measures an empty mirror,
// so the draft's arrival has to run it again.
const { view, textarea, shell } = bench()
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
// The restored draft ends in a newline, so the reveal takes the
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
mirror.style.lineHeight = '24px'
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
expect(scroll.scrollTop).toBe(0)
act(() => { shell.setDraft('restored\n'.repeat(40)) })
// The caret the machine left at the draft's end, revealed once the draft exists.
expect(textarea.selectionStart).toBe(textarea.value.length)
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')