diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 25131f1d246..b4995f8bb71 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -237,6 +237,9 @@ export function NativeChatMessageList({
diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index 4a57bfa348d..9cab78cee22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -46,7 +46,10 @@ export type NativeChatTranscriptWindow = { * is zoomed: rects are viewport pixels, `scrollTop` is not. Absolutely * positioned windowed rows are placed with `top`, never a transform, so this * stays true through the window as well. */ -export function nativeChatScrollOffsetWithin(element: HTMLElement, container: HTMLElement): number { +export function nativeChatScrollOffsetWithin( + element: HTMLElement, + container: HTMLElement +): number | null { let top = 0 let node: HTMLElement | null = element while (node !== null && node !== container) { @@ -57,12 +60,14 @@ export function nativeChatScrollOffsetWithin(element: HTMLElement, container: HT const parent = node.offsetParent as HTMLElement | null | undefined node = parent && typeof parent.offsetTop === 'number' ? parent : null } - if (node === container) { - return top - } - // No chain to walk — a positioned ancestor outside the scroller, or a DOM with - // no layout at all. Rects still describe the distance, in viewport pixels; the - // container's own measured zoom converts them back into scroll pixels. + return node === container ? top : null +} + +/** Same distance read off rects, for the case where there is no `offsetParent` + * chain to walk. Rects are viewport pixels, so the container's own measured + * zoom converts them back; a container with no layout reports no zoom and no + * distance, which leaves the offset where it already is. */ +function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number { const containerRect = container.getBoundingClientRect() const zoom = container.offsetHeight > 0 && containerRect.height > 0 @@ -91,9 +96,6 @@ export function useNativeChatTranscriptWindow({ () => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }), [slots.length, revealIndex] ) - const pinnedRef = useRef(pinned) - pinnedRef.current = pinned - // Stable identities: the virtualizer keys its measurement memo on these, so a // fresh closure per render would rebuild every row's offset on every frame. const estimateSize = useCallback( @@ -104,10 +106,15 @@ export function useNativeChatTranscriptWindow({ (index: number) => slotsRef.current[index]?.message.id ?? index, [] ) + // Identity tracks the pinned set on purpose. The virtualizer memoizes the + // mounted indexes on this function, so a stable one would keep serving the + // range from before a row was pinned — and a reveal would point at a row that + // never mounted. It is not a dependency of the measurement memo, so nothing + // expensive is rebuilt by changing it. const rangeExtractor = useCallback( (range: { startIndex: number; endIndex: number; overscan: number; count: number }) => - nativeChatTranscriptRange(range, pinnedRef.current), - [] + nativeChatTranscriptRange(range, pinned), + [pinned] ) const virtualizer = useVirtualizer({ @@ -133,8 +140,13 @@ export function useNativeChatTranscriptWindow({ if (!container || !sizer) { return } + // Only the offset chain, never the rect fallback: a container with no + // layout would report the scroll position itself as the margin, which would + // hold the window at the top of the transcript no matter where it scrolled. const offset = nativeChatScrollOffsetWithin(sizer, container) - setScrollMargin((current) => (current === offset ? current : offset)) + if (offset !== null) { + setScrollMargin((current) => (current === offset ? current : offset)) + } }, [scrollRef]) useLayoutEffect(readScrollMargin) @@ -173,7 +185,8 @@ export function useNativeChatTranscriptWindow({ if (!container) { return } - const top = nativeChatScrollOffsetWithin(element, container) + const top = + nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container) // Through the virtualizer so a scroll it is still reconciling — the jump // that mounted this row in the first place — is replaced rather than raced. if (virtualizer.scrollElement) {