From 9b0f41c8f0ef730548f961642d5a55e400a6eb4c Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 15 Jun 2026 13:59:48 +0300 Subject: [PATCH] fix(transcript): scroll to latest message on first open Opening the transcript on a long conversation used to land at the very first message instead of the most recent one. The initial fetch called scrollToBottom() inside a single requestAnimationFrame right after setMsgs(next), but React hadn't rendered the new bubbles yet so scrollHeight was still ~clientHeight and scrollTop = scrollHeight effectively landed at the top. Move the first-render jump into a useLayoutEffect keyed on the session id, which fires after the DOM is laid out (scrollHeight is correct) but before paint (no visible flicker). A ref tracks which session has already had its initial scroll done, so the jump runs exactly once per session/open cycle and live polls don't re-trigger it. The live-poll glue-to-bottom path is preserved for follow-up updates when the user is already at the bottom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/TranscriptPanel.tsx | 29 +++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/TranscriptPanel.tsx b/src/renderer/components/TranscriptPanel.tsx index 98f54f4..dcde4fc 100644 --- a/src/renderer/components/TranscriptPanel.tsx +++ b/src/renderer/components/TranscriptPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react'; +import React, { useEffect, useLayoutEffect, useState, useMemo, useRef, useCallback } from 'react'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; import { useTerminalStore } from '../state/terminal-store'; @@ -87,6 +87,10 @@ const TranscriptPanel: React.FC = () => { const panelRef = useRef(null); const bodyRef = useRef(null); const sigRef = useRef(''); + // Tracks which session id has had its initial scroll-to-bottom done. We jump + // to the latest message the first time a session's messages land in the DOM, + // so opening the transcript on a long chat doesn't dump you at the very top. + const initialScrolledForRef = useRef(null); const close = useCallback(() => useTerminalStore.setState({ transcriptOpen: false }), []); // ── Search state ────────────────────────────────────────────────── @@ -116,6 +120,7 @@ const TranscriptPanel: React.FC = () => { if (!open || !aiSessionId) { setMsgs(null); return; } let cancelled = false; sigRef.current = ''; + initialScrolledForRef.current = null; setMsgs(null); const fetchOnce = (initial: boolean) => { @@ -137,12 +142,14 @@ const TranscriptPanel: React.FC = () => { return; } } - const wasAtBottom = initial || atBottom(); + const wasAtBottom = atBottom(); sigRef.current = nextSig; setMsgs(next); - // Don't fight an active search: if the user is parked on a match, - // leave the scroll position where the search put it. - if (wasAtBottom && !searchActiveRef.current) scrollToBottom(); + // On live updates, keep glueing to the bottom if the user was already + // there. The *initial* scroll-to-bottom is handled by the layout + // effect below, because at this point the new bubbles haven't been + // laid out yet and scrollHeight is still ~0 (which lands at the top). + if (!initial && wasAtBottom && !searchActiveRef.current) scrollToBottom(); }) .catch(() => { if (!cancelled && initial) setMsgs([]); }); }; @@ -152,6 +159,18 @@ const TranscriptPanel: React.FC = () => { return () => { cancelled = true; clearInterval(timer); }; }, [open, aiSessionId, provider]); + // First-render jump: when a session's messages first hit the DOM, scroll the + // body to the bottom so opening the transcript on a long conversation lands + // on the most recent message instead of the very first one. + useLayoutEffect(() => { + if (!open || !aiSessionId || !msgs || msgs.length === 0) return; + if (initialScrolledForRef.current === aiSessionId) return; + const body = bodyRef.current; + if (!body) return; + body.scrollTop = body.scrollHeight; + initialScrolledForRef.current = aiSessionId; + }, [open, aiSessionId, msgs]); + // Escape: close the search bar first if it's open, otherwise close the panel. useEffect(() => { if (!open) return;