From 4a1bfc27ee1bbe8f9d117fa003674fdf38059ffe Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:15:10 +0900 Subject: [PATCH 1/2] fix(chat): preserve scroll position across chat tab switches (#321) Inactive chat tabs are hidden via display:none with the React tree kept mounted, so the virtualizer persists. On re-show, items briefly re-measure small while markdown re-lays-out; recording those shrinks collapsed getTotalSize(), clamping scrollTop to 0 and losing the position. Harden measureElement: key the size cache by message id, and during a short settle window after a hide->show transition refuse to shrink the cache (return max(measured, cached) without recording). Total size stays stable, scrollTop is never clamped, and the position is preserved to the pixel - no programmatic re-scroll, so no races with auto-scroll. --- src/ui/MessageList.tsx | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/ui/MessageList.tsx b/src/ui/MessageList.tsx index 3bb3163..c8da7dd 100644 --- a/src/ui/MessageList.tsx +++ b/src/ui/MessageList.tsx @@ -9,6 +9,13 @@ import { setIcon } from "obsidian"; import { MessageBubble } from "./MessageBubble"; import { useVirtualizer } from "@tanstack/react-virtual"; +// How long (ms) after a tab is re-shown we refuse to shrink measured item +// sizes. Right after re-show the items briefly re-measure small while their +// markdown re-lays-out; recording those shrinks collapses the virtualizer's +// total size, which clamps scrollTop to 0 and loses the position. Riding out +// this window keeps total stable so the scroll position is preserved. (#321) +const SHOW_SETTLE_MS = 500; + /** * Props for MessageList component */ @@ -66,6 +73,16 @@ export function MessageList({ const [isAtBottom, setIsAtBottom] = useState(true); const isAtBottomRef = useRef(true); const prevIsSendingRef = useRef(false); + // Last measured height per message id. Used to keep the virtualizer's total + // size stable while the tab is hidden (display:none) so scrollTop isn't + // clamped to 0 and the position survives a tab switch. (#321) + const sizeCacheRef = useRef>(new Map()); + // Whether the view was last seen hidden (display:none), and the end of the + // post-show "settle" window during which we refuse to shrink the size cache + // (see SHOW_SETTLE_MS). Together these suppress the transient total-size + // collapse that would otherwise clamp scrollTop to 0 on re-show. (#321) + const wasHiddenRef = useRef(false); + const settleUntilRef = useRef(0); // ============================================================ // Virtualizer @@ -75,6 +92,40 @@ export function MessageList({ getScrollElement: () => containerRef.current, estimateSize: () => 80, overscan: 5, + getItemKey: (index) => messages[index]?.id ?? String(index), + measureElement: (element) => { + const el = element as HTMLElement; + const id = el.getAttribute("data-msg-id"); + const cached = id ? sizeCacheRef.current.get(id) : undefined; + // Hidden (display:none): the item is detached from layout + // (offsetParent === null) and would measure 0. Remember we were + // hidden and return the last known size so the total size doesn't + // collapse on the hidden side. (#321) + if (el.offsetParent === null) { + wasHiddenRef.current = true; + return cached || 80; + } + // First measure after re-show: open the settle window. Opening it + // here (rather than from a separate observer) makes this very call + // guarded too, regardless of observer firing order. (#321) + if (wasHiddenRef.current) { + wasHiddenRef.current = false; + settleUntilRef.current = performance.now() + SHOW_SETTLE_MS; + } + const measured = el.getBoundingClientRect().height; + // Inside the settle window, never shrink: return the larger of the + // fresh and cached heights and don't record it, so getTotalSize() + // stays stable and scrollTop isn't clamped to 0 on re-show. Genuine + // shrinks are accepted again once the window expires. (#321) + if ( + cached !== undefined && + performance.now() < settleUntilRef.current + ) { + return Math.max(measured, cached); + } + if (id && measured > 0) sizeCacheRef.current.set(id, measured); + return measured || cached || 80; + }, }); // Suppress scroll position correction when user has scrolled up. @@ -203,6 +254,7 @@ export function MessageList({ key={message.id} ref={virtualizer.measureElement} data-index={virtualItem.index} + data-msg-id={message.id} className="agent-client-virtual-item" style={{ position: "absolute", From e26aacf7211a6f624dc0c9b4004688eb238dab3c Mon Sep 17 00:00:00 2001 From: RAIT-09 <51452399+RAIT-09@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:10:37 +0900 Subject: [PATCH 2/2] fix(chat): clear per-message size cache on message reset (#321) The msgId->height cache added for scroll preservation was only ever written, never cleared, so entries from old sessions lingered in this long-lived view and grew slowly over many new-chat/restore/fork cycles. Clear it in the existing empty-messages effect. All reset paths (new chat, restore, fork, restart) funnel through clearMessages() -> setMessages([]) first, so a single clear on length===0 covers them all. Mirrors how useAgentMessages clears toolCallIndexRef on reset. --- src/ui/MessageList.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/MessageList.tsx b/src/ui/MessageList.tsx index c8da7dd..9aaf7f8 100644 --- a/src/ui/MessageList.tsx +++ b/src/ui/MessageList.tsx @@ -157,11 +157,15 @@ export function MessageList({ return isNearBottom; }, []); - // Reset scroll state when messages are cleared (new chat) + // Reset scroll state and drop the per-message size cache when messages are + // cleared (new chat / restore / fork / restart all funnel through an empty + // array first). Prevents stale msgId→height entries from accumulating + // across sessions in this long-lived view. (#321) useEffect(() => { if (messages.length === 0) { setIsAtBottom(true); isAtBottomRef.current = true; + sizeCacheRef.current.clear(); } }, [messages.length]);