fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor (#38333)
* fix(desktop): prevent IME Enter from splitting messages and viewport resize from disarming scroll anchor Two fixes for the Hermes Desktop composer: 1. IME composition Enter was treated as message submission. When a Korean/ Japanese/Chinese IME is composing text and the user presses Enter to finalise the preedit, handleEditorKeyDown fired submitDraft() because it did not check event.nativeEvent.isComposing. The assistant-ui hidden textarea already guards this correctly; the custom contentEditable handler was missing it. Added an early return when isComposing is true. 2. Viewport resize (composer expand/collapse, window resize) was disarming the scroll sticky-bottom anchor. When the composer grows, the thread viewport shrinks, the browser adjusts scrollTop down to keep content visible, and the onScroll handler misread this as a user scroll-up. Added lastClientHeightRef tracking so the disarm condition now requires BOTH stable scrollHeight AND stable clientHeight before treating a scrollTop decrease as user intent. Fixes: random mid-message sends during IME typing; scroll jumps when the composer resizes or the window changes size. * fix(desktop): prevent virtualizer measurement adjustments from fighting scroll anchoring The virtualizer's measureElement callbacks trigger scroll adjustments when item sizes differ from estimates. These fight our ResizeObserver + pinToBottom loop, creating visible rubber-banding (view snaps to composer then jumps back up), even during idle. Three changes: 1. React.memo on VirtualizedThread to stop parent re-renders cascading 2. Shared stickyBottomRef so scrollToFn can check bottom state 3. scrollToFn override: skip adjustments when user is at bottom * fix(desktop): use stable useCallback ref instead of inline arrow for onBranchInNewChat The inline arrow `messageId => void branchInNewChat(messageId)` created a new function reference on every render. This cascaded through: desktop-controller → ChatView → Thread → useMemo([...onBranchInNewChat]) → new messageComponents object → VirtualizedThread receives new prop → React.memo overridden → virtualizer recalculates → measurement adjustments trigger scroll jumps at the 15-second useStatusSnapshot interval. Pass the already-useCallback'd branchInNewChat directly. * fix(desktop): use ctrlEnter submitMode on hidden textarea + gate ResizeObserver on isRunning Two root-cause fixes: 1. IME message splitting: The hidden ComposerPrimitive.Input textarea had submitMode='enter' (default), so any Enter keydown it received — even during IME composition — triggered form.requestSubmit(). Changed to submitMode='ctrlEnter' so only the contentEditable div (which correctly checks isComposing) handles plain-Enter submission. 2. Scroll jumps during idle: The ResizeObserver auto-follow loop was active even when the thread wasn't running, causing spurious pinToBottom calls whenever any layout shift occurred (browser reflow, font load, GPU cache eviction). Gated the ResizeObserver on thread.isRunning so auto-scroll only follows during active streaming. User messages still pin via useLayoutEffect, and thread.runStart still calls jumpToBottom. * fix(desktop): keep chat bottom anchor stable through idle layout shifts * fix(desktop): prevent code block shrink scroll bounce * fix(desktop): release bottom height lock on run completion * fix(desktop): keep streaming code blocks rendered * fix(desktop): keep bottom anchored through final render * fix(desktop): render streaming reasoning code blocks * feat(desktop): add subtle streaming block animations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { type ComponentProps, type FC, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
@@ -8,6 +8,7 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
const POST_RUN_BOTTOM_LOCK_MS = 1_200
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
@@ -55,7 +56,7 @@ function buildGroups(signature: string): MessageGroup[] {
|
||||
return groups
|
||||
}
|
||||
|
||||
export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
@@ -65,11 +66,16 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Shared ref so scrollToFn can check whether the user is parked at the
|
||||
// bottom without needing a ref from inside useThreadScrollAnchor.
|
||||
const stickyBottomRef = useRef(true)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||
@@ -78,14 +84,39 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
// Seed the rect so the initial range mounts something before
|
||||
// `observeElementRect` reports the real layout (it overrides this).
|
||||
initialRect: { height: 600, width: 800 },
|
||||
overscan: OVERSCAN
|
||||
overscan: OVERSCAN,
|
||||
// When the virtualizer adjusts scroll due to item measurement changes,
|
||||
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
|
||||
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
|
||||
// adjust creates a feedback loop where the two fight each other,
|
||||
// producing visible rubber-banding (the view snaps to the composer
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
const maxScroll = el.scrollHeight - el.clientHeight
|
||||
const distFromBottom = maxScroll - el.scrollTop
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
;(el as HTMLElement).scrollTo(0, offset)
|
||||
}
|
||||
})
|
||||
|
||||
useThreadScrollAnchor({
|
||||
enabled: !renderEmpty,
|
||||
groupCount: groups.length,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey: sessionKey ?? null,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
@@ -169,20 +200,34 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
isRunning: boolean
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
sessionKey: string | null
|
||||
stickyBottomRef: React.MutableRefObject<boolean>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) {
|
||||
// `armed` = parked at bottom, content growth should follow. Cleared on
|
||||
function useThreadScrollAnchor({
|
||||
enabled,
|
||||
groupCount,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
}: ScrollAnchorOptions) {
|
||||
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
|
||||
// user-driven upward scroll; re-armed when they reach bottom again.
|
||||
const armedRef = useRef(true)
|
||||
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
|
||||
// measurement adjustments from fighting our pinToBottom.
|
||||
const lastTopRef = useRef(0)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastClientHeightRef = useRef(0)
|
||||
// Counter that tracks how many scroll events we expect to be ours rather
|
||||
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
|
||||
// async `scroll` event; without this guard the on-scroll handler can race
|
||||
@@ -208,21 +253,22 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
el.scrollTop = el.scrollHeight
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}, [groupCount, pinToBottom, virtualizer])
|
||||
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
|
||||
|
||||
useEffect(() => () => setThreadScrolledUp(false), [])
|
||||
|
||||
@@ -236,7 +282,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
armedRef.current = false
|
||||
stickyBottomRef.current = false
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
@@ -254,39 +300,35 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
programmaticScrollPendingRef.current -= 1
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
setThreadScrolledUp(!atBottom)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disarm only when `scrollTop` decreases AND `scrollHeight` did NOT
|
||||
// grow this frame. A bare `top < lastTopRef.current` check is unsafe:
|
||||
// when content grows (virtualizer item measurement, streaming token,
|
||||
// code highlight re-tokenization, composer chip), the browser emits
|
||||
// an interim `scroll` event whose `scrollTop` is smaller than the
|
||||
// previous frame's because `scrollHeight` jumped — this fires before
|
||||
// the rAF-scheduled `pinToBottom` runs, so `programmaticScrollPendingRef`
|
||||
// is 0. Treating that as a user scroll permanently disarmed sticky-bottom
|
||||
// and produced the visible at-rest backward jump (#37997). Gating on a
|
||||
// stable `scrollHeight` keeps real user-driven upward intent — scrollbar
|
||||
// drag, keyboard PgUp, programmatic scrollIntoView — covered without
|
||||
// the false positive. Wheel-up and touchmove still disarm via their
|
||||
// own listeners below.
|
||||
// Disarm only when `scrollTop` decreases while both content height and
|
||||
// viewport height are stable. A bare `top < lastTopRef.current` check is
|
||||
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
|
||||
// window resizing, and toolbar/status updates can all move scrollTop as a
|
||||
// layout side effect. Wheel-up and touchmove still disarm immediately via
|
||||
// their own listeners below, so real user intent remains covered.
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
if (!heightGrew && top + 1 < lastTopRef.current) {
|
||||
armedRef.current = false
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
|
||||
stickyBottomRef.current = false
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
|
||||
if (atBottom) {
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
}
|
||||
|
||||
setThreadScrolledUp(!atBottom)
|
||||
@@ -307,7 +349,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', disarm)
|
||||
}
|
||||
}, [scrollerRef])
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// Follow content growth (streaming, item measurements, loading indicator)
|
||||
// while armed. During fast streaming the ResizeObserver can fire many
|
||||
@@ -316,7 +358,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
|
||||
// token.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
if (!enabled || !isRunning) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -328,13 +370,13 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
|
||||
let pinRafScheduled = false
|
||||
const schedulePin = () => {
|
||||
if (pinRafScheduled || !armedRef.current) {
|
||||
if (pinRafScheduled || !stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
pinRafScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
pinRafScheduled = false
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
@@ -350,7 +392,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, pinToBottom, scrollerRef])
|
||||
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
@@ -387,16 +429,56 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) {
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
requestAnimationFrame(() => {
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom])
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Completion swaps streaming placeholders/plain code for final rendered DOM
|
||||
// (notably Shiki-highlighted code). Keep following the bottom briefly after
|
||||
// `isRunning` flips false so that final measurement pass cannot strand the
|
||||
// viewport near the top of a large code block.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
|
||||
if (!enabled || !finishedRun || !stickyBottomRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
|
||||
let lockRaf: number | null = null
|
||||
|
||||
const lockFrame = () => {
|
||||
lockRaf = null
|
||||
|
||||
if (!stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
|
||||
if (performance.now() < lockUntil) {
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
}
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
|
||||
return () => {
|
||||
if (lockRaf !== null) {
|
||||
cancelAnimationFrame(lockRaf)
|
||||
}
|
||||
}
|
||||
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user