From 1b23d54a67e5775a403a417b9b48130862629074 Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Mon, 9 Mar 2026 11:13:34 +0530 Subject: [PATCH 1/3] feat(web): add scroll to bottom pill in chat view when scrolling up in a long thread there was no way to get back to the bottom other than switching threads and back. adds a small centered pill that appears in-flow between the messages and the input box whenever the user isn't pinned to the bottom. clicking it smooth-scrolls back down and the pill disappears. rendered in normal document flow rather than absolutely positioned so it can't be clipped by overflow-hidden ancestors or collide with elements above the composer. --- apps/web/src/components/ChatView.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b300ba8..419988ee8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -624,6 +624,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1843,6 +1844,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } } + setShowScrollToBottom(!shouldAutoScrollRef.current); lastKnownScrollTopRef.current = currentScrollTop; }, []); const onMessagesWheel = useCallback((event: React.WheelEvent) => { @@ -3524,6 +3526,20 @@ export default function ChatView({ threadId }: ChatViewProps) { /> + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
+ +
+ )} + {/* Input bar */}
Date: Fri, 13 Mar 2026 09:59:22 +0530 Subject: [PATCH 2/3] fix scroll to bottom pill positioning --- apps/web/src/components/ChatView.tsx | 69 ++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 419988ee8..a22f053a5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -625,6 +625,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [scrollPillPosition, setScrollPillPosition] = useState<{ + left: number; + width: number; + bottom: number; + } | null>(null); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -682,6 +687,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); + const chatColumnRef = useRef(null); + const composerViewportRef = useRef(null); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -696,6 +703,29 @@ export default function ChatView({ threadId }: ChatViewProps) { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); + const updateScrollPillPosition = useCallback(() => { + const chatColumn = chatColumnRef.current; + const composerViewport = composerViewportRef.current; + if (!chatColumn || !composerViewport) return; + + const chatRect = chatColumn.getBoundingClientRect(); + const composerRect = composerViewport.getBoundingClientRect(); + const nextLeft = Math.max(0, chatRect.left); + const nextWidth = Math.max(0, chatRect.width); + const nextBottom = Math.max(0, window.innerHeight - composerRect.top); + + setScrollPillPosition((previous) => { + if ( + previous && + Math.abs(previous.left - nextLeft) < 0.5 && + Math.abs(previous.width - nextWidth) < 0.5 && + Math.abs(previous.bottom - nextBottom) < 0.5 + ) { + return previous; + } + return { left: nextLeft, width: nextWidth, bottom: nextBottom }; + }); + }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), @@ -1923,6 +1953,25 @@ export default function ChatView({ threadId }: ChatViewProps) { observer.disconnect(); }; }, [activeThread?.id, scheduleStickToBottom]); + useLayoutEffect(() => { + updateScrollPillPosition(); + if (typeof ResizeObserver === "undefined") return; + const chatColumn = chatColumnRef.current; + const composerViewport = composerViewportRef.current; + if (!chatColumn || !composerViewport) return; + + const observer = new ResizeObserver(() => { + updateScrollPillPosition(); + }); + observer.observe(chatColumn); + observer.observe(composerViewport); + window.addEventListener("resize", updateScrollPillPosition); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateScrollPillPosition); + }; + }, [updateScrollPillPosition]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -3484,7 +3533,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
{/* Messages */}
{/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
+ {showScrollToBottom && scrollPillPosition && ( +
)} +
+ {/* Input bar */} -
+