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