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

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