From 40c118262466c3c7b6cb2a1e704b062abf79b50f Mon Sep 17 00:00:00 2001 From: Brendan DeBeasi Date: Sat, 9 May 2026 22:13:01 -0700 Subject: [PATCH 1/3] fix(chat): pin Cortex chat to bottom across async layout shifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #594. The previous useEffect-based scroll missed several height-changing events: tool result expansion (status flip without array length change), the ThinkingIndicator toggle (derived state, not a dep), and async markdown reflow (highlighter, fonts, image loads finish after the scroll has run). behavior: "smooth" also raced the layout shifts and landed short. Replaced with a ResizeObserver-based hook (useStickToBottom): - Observes the messages content directly, so any height change re-pins uniformly — no need to enumerate dep-array entries per layout source. - Tracks "near bottom" (within 64px) on scroll. New content only re-pins when the user is already at the bottom, so scrolling up to read history is preserved. - Uses behavior: "auto" so layout shifts during the scroll can't make it land short — the next observed shift snaps us forward again. Removes the old messagesEndRef sentinel; the ResizeObserver tracks the content element's height directly. --- interface/src/components/CortexChatPanel.tsx | 13 ++--- interface/src/hooks/useStickToBottom.ts | 59 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 interface/src/hooks/useStickToBottom.ts diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index 49b6bf56d..73e9d7b44 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; +import {useStickToBottom} from "@/hooks/useStickToBottom"; import {Markdown} from "@/components/Markdown"; import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; import { @@ -382,7 +383,8 @@ export function CortexChatPanel({ } = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt}); const [input, setInput] = useState(""); const [threadListOpen, setThreadListOpen] = useState(false); - const messagesEndRef = useRef(null); + const scrollRef = useRef(null); + const contentRef = useRef(null); const initialPromptSentRef = useRef(false); // Auto-send initial prompt once the fresh thread is ready @@ -399,9 +401,7 @@ export function CortexChatPanel({ } }, [initialPrompt, threadId, isStreaming, messages.length, sendMessage]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({behavior: "smooth"}); - }, [messages.length, isStreaming, toolActivity.length]); + useStickToBottom(scrollRef, contentRef); const handleSubmit = () => { const trimmed = input.trim(); @@ -469,8 +469,8 @@ export function CortexChatPanel({ )} {/* Messages */} -
-
+
+
{messages.map((message) => (
{message.role === "user" ? ( @@ -513,7 +513,6 @@ export function CortexChatPanel({ {error}
)} -
diff --git a/interface/src/hooks/useStickToBottom.ts b/interface/src/hooks/useStickToBottom.ts new file mode 100644 index 000000000..5b1c8333a --- /dev/null +++ b/interface/src/hooks/useStickToBottom.ts @@ -0,0 +1,59 @@ +import {useEffect, useRef, type RefObject} from "react"; + +/** Scroll within this many pixels of the bottom counts as "user is at the + * bottom" — small enough to feel pinned, large enough to forgive sub-pixel + * scroll offsets and momentum overshoot. */ +const NEAR_BOTTOM_PX = 64; + +/** Keeps a scroll container pinned to the bottom of its content while the + * user is already near the bottom; respects scroll-up intent so reading + * history isn't yanked back to bottom by new messages or async layout shifts. + * + * Why a `ResizeObserver` instead of an effect with content deps: tool result + * expansion, async markdown reflow (highlighter, fonts, images), and + * `ThinkingIndicator` toggling all change height without changing the deps + * a normal effect could watch. Observing the content directly catches them + * all uniformly. + * + * Uses `behavior: "auto"`: smooth scroll animations race intervening layout + * shifts and land short, which is the original bug. Auto repaints once, + * then the next observed shift snaps us forward again. */ +export function useStickToBottom( + scrollRef: RefObject, + contentRef: RefObject, +) { + const isPinnedRef = useRef(true); + + useEffect(() => { + const scroll = scrollRef.current; + const content = contentRef.current; + if (!scroll || !content) return; + + const isNearBottom = () => + scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < + NEAR_BOTTOM_PX; + + const scrollToBottom = () => { + scroll.scrollTop = scroll.scrollHeight; + }; + + // Land at the bottom on first mount regardless of the initial + // scrollTop value the browser remembered. + scrollToBottom(); + + const onScroll = () => { + isPinnedRef.current = isNearBottom(); + }; + scroll.addEventListener("scroll", onScroll, {passive: true}); + + const ro = new ResizeObserver(() => { + if (isPinnedRef.current) scrollToBottom(); + }); + ro.observe(content); + + return () => { + scroll.removeEventListener("scroll", onScroll); + ro.disconnect(); + }; + }, [scrollRef, contentRef]); +} From f89fbc2b759b1532ff790fa0160c33a9b4ddfc9c Mon Sep 17 00:00:00 2001 From: Brendan DeBeasi Date: Sun, 10 May 2026 10:05:52 -0700 Subject: [PATCH 2/3] fix(chat): re-snap on next two frames to catch post-scroll layout shifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported: "sometimes the response text scrolls down, but still doesn't expose the full response + copy button as it streams in." That matches a category of bugs the single-pass scroll missed: - Scrollbar appearing AFTER the initial scroll narrows the content's available width (non-overlay scrollbars), forcing one more line of wrap → content grows by ~1 row → we're now ~1 row above the new bottom. - Web-font swap: fallback → loaded font widens text → reflow → 1 row taller. - A hover-action button or toolbar mounted on the next frame (e.g. a message copy button) extends content past where we just scrolled. ResizeObserver does fire for these, but only once. The settleToBottom helper now snaps on the current frame plus the next two RAFs, which is enough to absorb the typical 1-2 frame delay these layout shifts have. isPinned is checked before each follow-up snap so a user scroll-up in between cancels the chase. Cheap to over-call — scrollTop = scrollHeight is a no-op once we're at the bottom. --- interface/src/hooks/useStickToBottom.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/interface/src/hooks/useStickToBottom.ts b/interface/src/hooks/useStickToBottom.ts index 5b1c8333a..b9c169708 100644 --- a/interface/src/hooks/useStickToBottom.ts +++ b/interface/src/hooks/useStickToBottom.ts @@ -33,13 +33,29 @@ export function useStickToBottom( scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < NEAR_BOTTOM_PX; - const scrollToBottom = () => { + /** Snap to the bottom now, then again on each of the next two + * animation frames. The follow-up snaps catch growth that happens + * AFTER the current ResizeObserver callback returns: scrollbar + * appearing and narrowing content (extra row of wrap), web-font + * swap, late Markdown layout (images, code blocks), or a sibling + * that mounts a frame later (e.g. a hover-action toolbar). Cheap to + * over-call — `scrollTop = scrollHeight` is a no-op once we're at + * the bottom. */ + const settleToBottom = () => { scroll.scrollTop = scroll.scrollHeight; + requestAnimationFrame(() => { + if (!isPinnedRef.current) return; + scroll.scrollTop = scroll.scrollHeight; + requestAnimationFrame(() => { + if (!isPinnedRef.current) return; + scroll.scrollTop = scroll.scrollHeight; + }); + }); }; // Land at the bottom on first mount regardless of the initial // scrollTop value the browser remembered. - scrollToBottom(); + settleToBottom(); const onScroll = () => { isPinnedRef.current = isNearBottom(); @@ -47,7 +63,7 @@ export function useStickToBottom( scroll.addEventListener("scroll", onScroll, {passive: true}); const ro = new ResizeObserver(() => { - if (isPinnedRef.current) scrollToBottom(); + if (isPinnedRef.current) settleToBottom(); }); ro.observe(content); From 3d714f2d56f4b9f2e564728ee42b194d95615da6 Mon Sep 17 00:00:00 2001 From: Brendan DeBeasi Date: Sun, 10 May 2026 20:16:07 -0700 Subject: [PATCH 3/3] fix(chat): apply useStickToBottom to PortalTimeline The portal chat (main agent conversation view) had the same scroll-pinning bug as the Cortex chat panel from #594, with its own copy of the logic: - Effect on [visibleItems.length, isTyping] missed tool-result expansion and the per-message copy-button mount that fires when MessageBubble's onCopy renders. - Effect on [sendCount] used behavior: "smooth" inside requestAnimationFrame, same animation-vs-layout-shift race the issue called out. Replaced both with the same useStickToBottom hook used in CortexChatPanel, plus a short useEffect that force-snaps to bottom on sendCount change (preserves the "I just sent a message, take me to my message" behavior even when the user had scrolled up). Channel timeline (ChannelDetail.tsx) uses flex-col-reverse for a CSS-only scroll pin and doesn't need this hook. --- .../src/components/portal/PortalTimeline.tsx | 44 ++++++------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/interface/src/components/portal/PortalTimeline.tsx b/interface/src/components/portal/PortalTimeline.tsx index f0fead486..89ab35ca5 100644 --- a/interface/src/components/portal/PortalTimeline.tsx +++ b/interface/src/components/portal/PortalTimeline.tsx @@ -1,4 +1,5 @@ import {useEffect, useRef} from "react"; +import {useStickToBottom} from "@/hooks/useStickToBottom"; import {useQuery} from "@tanstack/react-query"; import {InlineBranchCard, MessageBubble} from "@spacedrive/ai"; import {File as FileIcon} from "@phosphor-icons/react"; @@ -188,7 +189,7 @@ export function PortalTimeline({ sendCount, }: PortalTimelineProps) { const scrollRef = useRef(null); - const previousLengthRef = useRef(0); + const contentRef = useRef(null); // Fetch workers for this channel to resolve worker_run items. const workersQuery = useQuery({ @@ -211,37 +212,20 @@ export function PortalTimeline({ return workerIds.has(item.id); }); - // Smart auto-scroll: only when near bottom - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - const previousLength = previousLengthRef.current; - const currentLength = visibleItems.length; - const distanceFromBottom = - element.scrollHeight - element.scrollTop - element.clientHeight; - const isNearBottom = distanceFromBottom < 160; - const shouldAutoScroll = - (currentLength > previousLength || isTyping) && - (previousLength === 0 || isNearBottom); - - if (shouldAutoScroll) { - requestAnimationFrame(() => { - element.scrollTo({top: element.scrollHeight, behavior: "auto"}); - }); - } - - previousLengthRef.current = currentLength; - }, [visibleItems.length, isTyping]); + // Stick to bottom: ResizeObserver catches tool-result expansion, async + // markdown reflow (highlighter, fonts, images), MessageBubble copy-action + // mount, and the streaming text growth uniformly. Preserves scroll-up + // intent. + useStickToBottom(scrollRef, contentRef); - // Always scroll to bottom when the user sends a message. + // Force-pin to bottom when the user sends a message — even if they had + // scrolled up to read history, they expect to see their own message + // land at the bottom. useEffect(() => { if (sendCount === 0) return; - const element = scrollRef.current; - if (!element) return; - requestAnimationFrame(() => { - element.scrollTo({top: element.scrollHeight, behavior: "smooth"}); - }); + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; }, [sendCount]); const copyMessage = async (content: string) => { @@ -250,7 +234,7 @@ export function PortalTimeline({ return (
-
+
{visibleItems.map((item) => { if (item.type === "message") { const attachments = item.attachments ?? [];