From 72f3bb096115c3ffb9bba01e708c3e55a5aea433 Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 25 Sep 2026 12:31:57 +0530 Subject: [PATCH] buddy scroll --- src/components/shell/AssistantPanel.tsx | 63 +++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index c0a53cb..b7e8c93 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -350,6 +350,7 @@ export function AssistantPanel({ const [isFocused, setIsFocused] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const textareaRef = useRef(null); + const threadRef = useRef(null); const { width, setWidth, reset } = useAssistantWidth(); const [isDragging, setDragging] = useState(false); @@ -405,6 +406,30 @@ export function AssistantPanel({ setThread([]); }, [key]); + /* + * Follow the conversation down, unless the person has scrolled away from it. + * + * A chat that does not follow leaves the newest answer below the fold, which + * reads as nothing having happened. One that follows unconditionally yanks + * somebody out of an earlier answer they were still reading the moment a + * reply lands — and a reply can land a while after the question, because a + * tool call and a model round trip are seconds, not milliseconds. + * + * So: only when they were already at the bottom. The 40px allowance covers + * fractional scroll heights, which browsers disagree about by a pixel or two + * at non-integer zoom levels — without it this silently stops following for + * anybody not at 100%. + */ + useEffect(() => { + const el = threadRef.current; + if (!el) return; + + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom > 40) return; + + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }, [thread]); + /* * Approving is its own call, with no question in it. * @@ -569,6 +594,35 @@ export function AssistantPanel({ )} + {/* + The scrolling part, and the only one. + + Everything above this is chrome that stays put; everything below it is + the composer, which must never leave the screen. Before this existed + the panel was one `height: 100%` column with no overflow anywhere: a + thread longer than the panel pushed the chips and the composer past + the bottom edge, and there was nothing to scroll — the box you type + into simply left the screen and the conversation could not be read + back. + + `minHeight: 0` is the load-bearing half. A flex child defaults to + `min-height: auto`, which means it refuses to shrink below its content, + so `flex: 1` alone would let this grow to the height of the whole + conversation and overflow the panel exactly as before — the scrollbar + never appears and nothing looks wrong in the code. + */} +
{/* Greeting — top-aligned, because it is the first thing in a conversation, not a splash screen. */}
@@ -608,9 +662,9 @@ export function AssistantPanel({

- {/* The conversation. Above the spacer, so a short thread sits under the - greeting and a long one pushes the chips and composer down rather - than scrolling away from them. */} + {/* The conversation. Inside the scroller with the greeting above it, so + a short thread reads straight on from the greeting and a long one + scrolls under it — with the chips and the composer staying put. */} {thread.length > 0 ? (
) : null} - -
+
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge is a bug no amount of fade masking fixes. */}